Files
medical-mall/pages/mall/merchant/chat-workbench.uvue

852 lines
40 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 机构端 - 客服工作台(演示版,全 mock 数据) -->
<template>
<view class="workbench">
<!-- ===== 顶部导航 ===== -->
<view class="wb-header">
<view class="wb-back" @click="goBack">
<text class="wb-back-icon"></text>
</view>
<text class="wb-header-title">客服工作台</text>
<view class="wb-header-right">
<view class="wb-status-wrap">
<view class="wb-status-dot"></view>
<text class="wb-status-text">在线</text>
</view>
<view class="wb-pending-wrap" v-if="pendingCount > 0">
<text class="wb-pending-text">{{ pendingCount }}条待处理</text>
</view>
</view>
</view>
<!-- ===== 三模块 Tab 切换栏 ===== -->
<view class="wb-tabs">
<view class="wb-tab-item" :class="{ 'wb-tab-active': activeTab === 0 }" @click="activeTab = 0">
<text class="wb-tab-icon">💬</text>
<text class="wb-tab-label">会话列表</text>
<view v-if="totalUnread > 0 && activeTab !== 0" class="wb-tab-badge">
<text class="wb-tab-badge-num">{{ totalUnread }}</text>
</view>
</view>
<view class="wb-tab-item" :class="{ 'wb-tab-active': activeTab === 1 }" @click="activeTab = 1">
<text class="wb-tab-icon">🗨</text>
<text class="wb-tab-label">聊天记录</text>
</view>
<view class="wb-tab-item" :class="{ 'wb-tab-active': activeTab === 2 }" @click="activeTab = 2">
<text class="wb-tab-icon">📋</text>
<text class="wb-tab-label">用户资料</text>
</view>
</view>
<!-- 当前会话提示条 -->
<view v-if="activeTab !== 0" class="session-hint-bar">
<text class="session-hint-avatar">{{ currentSession.avatar }}</text>
<text class="session-hint-name">{{ currentSession.userName }}</text>
<view class="session-hint-status" :class="'shs-' + currentSession.status">
<text class="session-hint-status-text">{{ currentSession.status === 'pending' ? '待回复' : currentSession.status === 'active' ? '服务中' : '已结束' }}</text>
</view>
<view class="session-hint-switch" @click="activeTab = 0">
<text class="session-hint-switch-text">切换会话</text>
</view>
</view>
<!-- ===== 内容区 ===== -->
<view class="wb-content">
<!-- ──────────── 模块一:会话列表 ──────────── -->
<view v-if="activeTab === 0" class="module-sessions">
<view class="sessions-search-wrap">
<input class="sessions-search-input" placeholder="🔍 搜索用户或会话内容..." v-model="searchKey" />
</view>
<scroll-view scroll-y class="sessions-scroll">
<view
v-for="sess in filteredSessions"
:key="sess.id"
class="session-card"
:class="{ 'session-card-active': currentSessionId === sess.id }"
@click="selectSession(sess.id)"
>
<view class="sc-avatar-wrap">
<text class="sc-avatar-emoji">{{ sess.avatar }}</text>
<view v-if="sess.unread > 0" class="sc-unread-badge">
<text class="sc-unread-num">{{ sess.unread > 99 ? '99+' : sess.unread }}</text>
</view>
</view>
<view class="sc-info">
<view class="sc-row-top">
<text class="sc-name">{{ sess.userName }}</text>
<text class="sc-time">{{ sess.lastTime }}</text>
</view>
<text class="sc-consult-type">{{ sess.consultType }}</text>
<view class="sc-row-bottom">
<text class="sc-preview" :class="{ 'sc-preview-bold': sess.unread > 0 }">{{ sess.lastMsg }}</text>
<view class="sc-status-tag" :class="'sc-status-' + sess.status">
<text class="sc-status-text">{{ sess.status === 'pending' ? '待回复' : sess.status === 'active' ? '服务中' : '已结束' }}</text>
</view>
</view>
</view>
<text class="sc-arrow"></text>
</view>
<view v-if="filteredSessions.length === 0" class="sessions-empty">
<text class="sessions-empty-icon">🔍</text>
<text class="sessions-empty-text">暂无匹配会话</text>
</view>
</scroll-view>
</view>
<!-- ──────────── 模块二:聊天记录 ──────────── -->
<view v-else-if="activeTab === 1" class="module-chat">
<scroll-view scroll-y class="chat-messages-scroll" :scroll-into-view="scrollTarget" :scroll-with-animation="true">
<view class="chat-messages-inner">
<view
v-for="msg in currentMessages"
:key="msg.id"
:id="'msg_' + msg.id"
class="msg-row"
:class="{ 'msg-row-system': msg.type === 'system' }"
>
<!-- 系统消息 -->
<view v-if="msg.type === 'system'" class="msg-system">
<text class="msg-system-text">{{ msg.content }}</text>
</view>
<!-- 用户消息(左) -->
<view v-else-if="msg.fromUser" class="msg-left">
<text class="msg-avatar">{{ currentSession.avatar }}</text>
<view class="msg-body">
<text class="msg-sender">{{ currentSession.userName }}</text>
<view v-if="msg.type === 'text'" class="bubble bubble-user">
<text class="bubble-text">{{ msg.content }}</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'image'" class="bubble bubble-user bubble-image">
<text class="bubble-image-icon">🖼</text>
<text class="bubble-image-hint">[图片消息]</text>
<text class="bubble-time">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'product'" class="msg-card-wrap">
<view class="product-card">
<view class="pc-thumb">
<text class="pc-thumb-emoji">{{ msg.cardData != null ? (msg.cardData as ProductCardData).emoji : '📦' }}</text>
</view>
<view class="pc-info">
<text class="pc-name">{{ msg.cardData != null ? (msg.cardData as ProductCardData).name : '' }}</text>
<text class="pc-price">¥{{ msg.cardData != null ? (msg.cardData as ProductCardData).price : '' }}</text>
<text class="pc-tag">{{ msg.cardData != null ? (msg.cardData as ProductCardData).tag : '' }}</text>
</view>
</view>
<text class="card-time">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'order'" class="msg-card-wrap">
<view class="order-card">
<view class="oc-row1">
<text class="oc-label">订单号</text>
<text class="oc-no">{{ msg.cardData != null ? (msg.cardData as OrderCardData).orderNo : '' }}</text>
<view class="oc-status-tag" :class="'oc-' + (msg.cardData != null ? (msg.cardData as OrderCardData).statusCode : '')">
<text class="oc-status-text">{{ msg.cardData != null ? (msg.cardData as OrderCardData).status : '' }}</text>
</view>
</view>
<text class="oc-amount">¥{{ msg.cardData != null ? (msg.cardData as OrderCardData).amount : '' }}</text>
<text class="oc-items">{{ msg.cardData != null ? (msg.cardData as OrderCardData).itemsDesc : '' }}</text>
</view>
<text class="card-time">{{ msg.time }}</text>
</view>
</view>
</view>
<!-- 商家消息(右) -->
<view v-else class="msg-right">
<view class="msg-body msg-body-right">
<view v-if="msg.type === 'text'" class="bubble bubble-me">
<text class="bubble-text">{{ msg.content }}</text>
<text class="bubble-time bubble-time-right">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'image'" class="bubble bubble-me bubble-image">
<text class="bubble-image-icon">🖼</text>
<text class="bubble-image-hint">[图片消息]</text>
<text class="bubble-time bubble-time-right">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'product'" class="msg-card-wrap msg-card-right">
<view class="product-card">
<view class="pc-thumb">
<text class="pc-thumb-emoji">{{ msg.cardData != null ? (msg.cardData as ProductCardData).emoji : '📦' }}</text>
</view>
<view class="pc-info">
<text class="pc-name">{{ msg.cardData != null ? (msg.cardData as ProductCardData).name : '' }}</text>
<text class="pc-price">¥{{ msg.cardData != null ? (msg.cardData as ProductCardData).price : '' }}</text>
<text class="pc-tag">{{ msg.cardData != null ? (msg.cardData as ProductCardData).tag : '' }}</text>
</view>
</view>
<text class="card-time card-time-right">{{ msg.time }}</text>
</view>
<view v-else-if="msg.type === 'order'" class="msg-card-wrap msg-card-right">
<view class="order-card">
<view class="oc-row1">
<text class="oc-label">订单号</text>
<text class="oc-no">{{ msg.cardData != null ? (msg.cardData as OrderCardData).orderNo : '' }}</text>
<view class="oc-status-tag" :class="'oc-' + (msg.cardData != null ? (msg.cardData as OrderCardData).statusCode : '')">
<text class="oc-status-text">{{ msg.cardData != null ? (msg.cardData as OrderCardData).status : '' }}</text>
</view>
</view>
<text class="oc-amount">¥{{ msg.cardData != null ? (msg.cardData as OrderCardData).amount : '' }}</text>
<text class="oc-items">{{ msg.cardData != null ? (msg.cardData as OrderCardData).itemsDesc : '' }}</text>
</view>
<text class="card-time card-time-right">{{ msg.time }}</text>
</view>
</view>
<text class="msg-avatar me-avatar">🏥</text>
</view>
</view>
</view>
</scroll-view>
<!-- 底部输入区 -->
<view class="chat-input-area">
<view v-if="showQuickReplies" class="quick-reply-panel">
<view class="qr-header">
<text class="qr-title">⚡ 快捷回复</text>
<text class="qr-close" @click="showQuickReplies = false">✕</text>
</view>
<scroll-view scroll-y class="qr-scroll">
<view v-for="qr in quickReplies" :key="qr" class="qr-item" @click="sendQuickReply(qr)">
<text class="qr-text">{{ qr }}</text>
</view>
</scroll-view>
</view>
<view class="chat-toolbar">
<view class="toolbar-btn" @click="insertImageMsg">
<text class="toolbar-icon">🖼</text>
<text class="toolbar-label">图片</text>
</view>
<view class="toolbar-btn" @click="insertProductMsg">
<text class="toolbar-icon">📦</text>
<text class="toolbar-label">商品</text>
</view>
<view class="toolbar-btn" @click="insertOrderMsg">
<text class="toolbar-icon">📋</text>
<text class="toolbar-label">订单</text>
</view>
<view class="toolbar-btn" @click="showQuickReplies = !showQuickReplies" :class="{ 'toolbar-btn-active': showQuickReplies }">
<text class="toolbar-icon">⚡</text>
<text class="toolbar-label">快捷</text>
</view>
</view>
<view class="input-row">
<textarea class="chat-textarea" v-model="inputText" placeholder="输入回复内容..." :maxlength="500" />
<view class="send-btn" :class="{ 'send-btn-active': inputText.trim() !== '' }" @click="sendTextMsg">
<text class="send-btn-text">发送</text>
</view>
</view>
</view>
</view>
<!-- ──────────── 模块三:用户资料 ──────────── -->
<view v-else-if="activeTab === 2" class="module-info">
<view class="info-user-card">
<text class="info-big-avatar">{{ currentSession.avatar }}</text>
<view class="info-user-main">
<text class="info-user-name">{{ currentSession.userName }}</text>
<view class="info-tags-row">
<view v-for="tag in (currentUserInfo.tags as string[])" :key="tag" class="info-tag">
<text class="info-tag-text">{{ tag }}</text>
</view>
</view>
</view>
</view>
<view class="info-section">
<view class="info-section-header">
<text class="info-section-icon">👤</text>
<text class="info-section-title">基本信息</text>
</view>
<view class="info-kv">
<text class="info-key">手机号</text>
<text class="info-val">{{ currentUserInfo.phone }}</text>
</view>
<view class="info-kv">
<text class="info-key">注册时间</text>
<text class="info-val">{{ currentUserInfo.registerDate }}</text>
</view>
<view class="info-kv">
<text class="info-key">历史订单</text>
<text class="info-val info-val-red">{{ currentUserInfo.totalOrders }} 单</text>
</view>
<view class="info-kv">
<text class="info-key">累计消费</text>
<text class="info-val info-val-red">¥{{ currentUserInfo.totalSpent }}</text>
</view>
</view>
<view class="info-section" v-if="currentUserInfo.consultProduct != null">
<view class="info-section-header">
<text class="info-section-icon">🛒</text>
<text class="info-section-title">正在咨询</text>
</view>
<view class="consult-product">
<text class="cp-big-emoji">{{ (currentUserInfo.consultProduct as ConsultProduct).emoji }}</text>
<view class="cp-detail">
<text class="cp-pname">{{ (currentUserInfo.consultProduct as ConsultProduct).name }}</text>
<text class="cp-pprice">¥{{ (currentUserInfo.consultProduct as ConsultProduct).price }}</text>
</view>
</view>
</view>
<view class="info-section">
<view class="info-section-header">
<text class="info-section-icon">👁</text>
<text class="info-section-title">最近浏览</text>
</view>
<view v-for="item in (currentUserInfo.recentViewed as RecentItem[])" :key="item.name" class="recent-row">
<text class="recent-emoji">{{ item.emoji }}</text>
<view class="recent-detail">
<text class="recent-name">{{ item.name }}</text>
<text class="recent-price">¥{{ item.price }}</text>
</view>
</view>
</view>
<view class="info-section">
<view class="info-section-header">
<text class="info-section-icon">📋</text>
<text class="info-section-title">最近订单</text>
</view>
<view v-for="order in (currentUserInfo.recentOrders as RecentOrder[])" :key="order.no" class="order-mini-card">
<view class="omc-row1">
<text class="omc-no">{{ order.no }}</text>
<view class="omc-status-tag" :class="'omc-' + order.statusCode">
<text class="omc-status-text">{{ order.status }}</text>
</view>
</view>
<view class="omc-row2">
<text class="omc-items">{{ order.itemsDesc }}</text>
<text class="omc-amount">¥{{ order.amount }}</text>
</view>
</view>
</view>
<view class="info-actions">
<view class="info-action-btn primary" @click="goToChat">
<text class="info-action-text">进入聊天</text>
</view>
<view class="info-action-btn" @click="closeSession">
<text class="info-action-text">结束会话</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
type ProductCardData = {
emoji: string
name: string
price: string
tag: string
}
type OrderCardData = {
orderNo: string
status: string
statusCode: string
amount: string
itemsDesc: string
}
type ChatMessage = {
id: string
fromUser: boolean
type: string
content: string
time: string
cardData: ProductCardData | OrderCardData | null
}
type Session = {
id: string
userName: string
avatar: string
lastMsg: string
lastTime: string
unread: number
status: string
consultType: string
}
type ConsultProduct = {
emoji: string
name: string
price: string
}
type RecentItem = {
emoji: string
name: string
price: string
}
type RecentOrder = {
no: string
status: string
statusCode: string
amount: string
itemsDesc: string
}
type UserInfo = {
phone: string
registerDate: string
totalOrders: number
totalSpent: string
tags: string[]
consultProduct: ConsultProduct | null
recentViewed: RecentItem[]
recentOrders: RecentOrder[]
}
const MOCK_SESSIONS: Session[] = [
{ id: 'sess_001', userName: '李奶奶(家属:李女士)', avatar: '👩‍🦳', lastMsg: '请问居家护理服务是每天上门吗?', lastTime: '10:32', unread: 3, status: 'pending', consultType: '居家护理咨询' },
{ id: 'sess_002', userName: '王大爷', avatar: '👴', lastMsg: '好的,我明白了,谢谢!', lastTime: '09:15', unread: 0, status: 'active', consultType: '慢病管理套餐' },
{ id: 'sess_003', userName: '张先生(代父咨询)', avatar: '🧑', lastMsg: '这个订单什么时候能安排上门?', lastTime: '昨天', unread: 1, status: 'pending', consultType: '订单跟进' }
]
const MOCK_MESSAGES: Record<string, ChatMessage[]> = {
'sess_001': [
{ id: 'm001', fromUser: false, type: 'system', content: '会话开始 · 居家护理咨询 · 2026-03-24 10:20', time: '', cardData: null },
{ id: 'm002', fromUser: true, type: 'text', content: '您好,我是李奶奶的女儿,想咨询一下居家护理服务的安排。', time: '10:20', cardData: null },
{ id: 'm003', fromUser: false, type: 'text', content: '您好!感谢您的咨询,我们的居家护理服务可以每日或隔日上门,具体频次根据长者身体状况定制。', time: '10:21', cardData: null },
{ id: 'm004', fromUser: true, type: 'text', content: '请问上门的护理员都是专业持证的吗?', time: '10:22', cardData: null },
{ id: 'm005', fromUser: false, type: 'text', content: '是的,我们所有上门人员均持有护理员资格证,并经过机构内部培训和背景审查。', time: '10:23', cardData: null },
{ id: 'm006', fromUser: false, type: 'product', content: '', time: '10:25', cardData: { emoji: '🩺', name: '居家护理基础套餐(月服务)', price: '2800', tag: '每日上门 · 含基础生活护理' } as ProductCardData },
{ id: 'm007', fromUser: true, type: 'image', content: '', time: '10:28', cardData: null },
{ id: 'm008', fromUser: true, type: 'text', content: '请问居家护理服务是每天上门吗?', time: '10:32', cardData: null }
],
'sess_002': [
{ id: 'm101', fromUser: false, type: 'system', content: '会话开始 · 慢病管理套餐 · 2026-03-24 09:00', time: '', cardData: null },
{ id: 'm102', fromUser: true, type: 'text', content: '我父亲有高血压和糖尿病,想了解一下慢病管理套餐包含哪些内容?', time: '09:00', cardData: null },
{ id: 'm103', fromUser: false, type: 'text', content: '您好我们慢病管理套餐包含每月4次上门随访、血压血糖监测记录、用药指导和饮食建议以及每季度体检报告解读。', time: '09:01', cardData: null },
{ id: 'm104', fromUser: false, type: 'product', content: '', time: '09:02', cardData: { emoji: '💊', name: '慢病综合管理套餐(季度)', price: '4200', tag: '含随访+监测+体检解读' } as ProductCardData },
{ id: 'm105', fromUser: true, type: 'text', content: '价格合理,这个套餐可以报销医保吗?', time: '09:05', cardData: null },
{ id: 'm106', fromUser: false, type: 'text', content: '目前部分项目可以使用长护险,具体需要根据您所在区域的长护险政策,我们可以协助您申请。', time: '09:06', cardData: null },
{ id: 'm107', fromUser: true, type: 'order', content: '', time: '09:10', cardData: { orderNo: 'SV20240315002', status: '服务中', statusCode: 'active', amount: '2800.00', itemsDesc: '居家护理基础套餐 × 1月' } as OrderCardData },
{ id: 'm108', fromUser: true, type: 'text', content: '好的,我明白了,谢谢!', time: '09:15', cardData: null }
],
'sess_003': [
{ id: 'm201', fromUser: false, type: 'system', content: '会话开始 · 订单跟进 · 2026-03-23 15:00', time: '', cardData: null },
{ id: 'm202', fromUser: true, type: 'text', content: '你好,我父亲上周下了一个陪诊服务的订单,现在是什么状态了?', time: '昨天 15:00', cardData: null },
{ id: 'm203', fromUser: false, type: 'text', content: '您好!请问您的订单号是多少?我来为您查询。', time: '昨天 15:01', cardData: null },
{ id: 'm204', fromUser: true, type: 'order', content: '', time: '昨天 15:02', cardData: { orderNo: 'SV20240320007', status: '待上门', statusCode: 'pending', amount: '380.00', itemsDesc: '专业陪诊服务(半日) × 1次' } as OrderCardData },
{ id: 'm205', fromUser: false, type: 'text', content: '已为您查到,该订单目前处于"待上门"状态陪诊师将于明天上午9:00~10:00联系您父亲确认时间。', time: '昨天 15:05', cardData: null },
{ id: 'm206', fromUser: false, type: 'text', content: '您也可以在小程序"服务订单"中实时查看服务进度,如需备注特殊要求请告知。', time: '昨天 15:06', cardData: null },
{ id: 'm207', fromUser: true, type: 'text', content: '这个订单什么时候能安排上门?', time: '昨天 15:08', cardData: null }
]
}
const MOCK_USER_INFO: Record<string, UserInfo> = {
'sess_001': {
phone: '186****3421', registerDate: '2025-11-10', totalOrders: 4, totalSpent: '8,400',
tags: ['长者家属', '居家护理', '高意向'],
consultProduct: { emoji: '🩺', name: '居家护理基础套餐', price: '2800' } as ConsultProduct,
recentViewed: [
{ emoji: '🩺', name: '居家护理基础套餐', price: '2800' } as RecentItem,
{ emoji: '🛁', name: '洗浴护理服务', price: '188' } as RecentItem,
{ emoji: '🦽', name: '轮椅租赁月服务', price: '360' } as RecentItem
],
recentOrders: [
{ no: 'SV20240210003', status: '已完成', statusCode: 'done', amount: '2800', itemsDesc: '居家护理基础套餐' } as RecentOrder,
{ no: 'SV20240115001', status: '已完成', statusCode: 'done', amount: '188', itemsDesc: '洗浴护理服务 × 1次' } as RecentOrder
]
},
'sess_002': {
phone: '139****8802', registerDate: '2025-08-22', totalOrders: 7, totalSpent: '15,600',
tags: ['高消费', '慢病管理', '复购客户'],
consultProduct: { emoji: '💊', name: '慢病综合管理套餐', price: '4200' } as ConsultProduct,
recentViewed: [
{ emoji: '💊', name: '慢病综合管理套餐', price: '4200' } as RecentItem,
{ emoji: '🩸', name: '上门抽血检测服务', price: '98' } as RecentItem,
{ emoji: '❤️', name: '心电图上门检测', price: '128' } as RecentItem
],
recentOrders: [
{ no: 'SV20240315002', status: '服务中', statusCode: 'active', amount: '2800', itemsDesc: '居家护理基础套餐' } as RecentOrder,
{ no: 'SV20240201008', status: '已完成', statusCode: 'done', amount: '4200', itemsDesc: '慢病综合管理套餐' } as RecentOrder
]
},
'sess_003': {
phone: '158****7760', registerDate: '2026-01-05', totalOrders: 1, totalSpent: '380',
tags: ['新用户', '陪诊咨询'],
consultProduct: { emoji: '🏥', name: '专业陪诊服务(半日)', price: '380' } as ConsultProduct,
recentViewed: [
{ emoji: '🏥', name: '专业陪诊服务(半日)', price: '380' } as RecentItem,
{ emoji: '🚑', name: '专业陪诊服务(全日)', price: '680' } as RecentItem
],
recentOrders: [
{ no: 'SV20240320007', status: '待上门', statusCode: 'pending', amount: '380', itemsDesc: '专业陪诊服务(半日)× 1次' } as RecentOrder
]
}
}
const QUICK_REPLIES: string[] = [
'您好,感谢您的咨询!我是在线客服,请问有什么可以帮您?',
'好的,我马上为您查询,请稍候。',
'我们的服务人员均持有专业资格证,请放心。',
'您的订单已安排,服务人员将提前联系您确认时间。',
'如需进一步了解,欢迎预约免费上门评估服务。',
'感谢您的信任,祝长辈身体健康!'
]
export default {
data() {
return {
sessions: MOCK_SESSIONS as Session[],
messageMap: MOCK_MESSAGES as Record<string, ChatMessage[]>,
userInfoMap: MOCK_USER_INFO as Record<string, UserInfo>,
quickReplies: QUICK_REPLIES as string[],
activeTab: 0,
currentSessionId: 'sess_001',
inputText: '',
scrollTarget: '',
showQuickReplies: false,
searchKey: ''
}
},
computed: {
filteredSessions(): Session[] {
if (this.searchKey.trim() === '') return this.sessions
const key = this.searchKey.toLowerCase()
return this.sessions.filter((s: Session) =>
s.userName.toLowerCase().includes(key) || s.lastMsg.toLowerCase().includes(key)
)
},
currentSession(): Session {
const found = this.sessions.find((s: Session) => s.id === this.currentSessionId)
return found ?? this.sessions[0]
},
currentMessages(): ChatMessage[] {
return this.messageMap[this.currentSessionId] ?? []
},
currentUserInfo(): UserInfo {
return this.userInfoMap[this.currentSessionId] ?? this.userInfoMap['sess_001']
},
pendingCount(): number {
let count = 0
for (let i = 0; i < this.sessions.length; i++) {
if (this.sessions[i].status === 'pending') count++
}
return count
},
totalUnread(): number {
let total = 0
for (let i = 0; i < this.sessions.length; i++) {
total += this.sessions[i].unread
}
return total
}
},
onLoad() {
this.scrollToBottom()
},
methods: {
selectSession(id: string) {
this.currentSessionId = id
this.showQuickReplies = false
const idx = this.sessions.findIndex((s: Session) => s.id === id)
if (idx >= 0) {
this.sessions[idx].unread = 0
if (this.sessions[idx].status === 'pending') this.sessions[idx].status = 'active'
}
this.activeTab = 1
setTimeout(() => { this.scrollToBottom() }, 150)
},
goToChat() {
this.activeTab = 1
setTimeout(() => { this.scrollToBottom() }, 150)
},
scrollToBottom() {
const msgs = this.currentMessages
if (msgs.length === 0) return
const last = msgs[msgs.length - 1]
this.scrollTarget = ''
setTimeout(() => { this.scrollTarget = 'msg_' + last.id }, 100)
},
nowTime(): string {
const d = new Date()
return d.getHours().toString().padStart(2, '0') + ':' + d.getMinutes().toString().padStart(2, '0')
},
pushMessage(msg: ChatMessage) {
if (this.messageMap[this.currentSessionId] == null) {
this.messageMap[this.currentSessionId] = []
}
this.messageMap[this.currentSessionId].push(msg)
const idx = this.sessions.findIndex((s: Session) => s.id === this.currentSessionId)
if (idx >= 0) {
this.sessions[idx].lastMsg = msg.type === 'text' ? msg.content :
msg.type === 'image' ? '[图片]' :
msg.type === 'product' ? '[商品推荐]' : '[订单信息]'
this.sessions[idx].lastTime = this.nowTime()
}
setTimeout(() => { this.scrollToBottom() }, 100)
},
sendTextMsg() {
const text = this.inputText.trim()
if (text === '') return
this.inputText = ''
this.showQuickReplies = false
this.pushMessage({ id: 'local_' + Date.now().toString(), fromUser: false, type: 'text', content: text, time: this.nowTime(), cardData: null } as ChatMessage)
},
sendQuickReply(text: string) {
this.showQuickReplies = false
this.pushMessage({ id: 'qr_' + Date.now().toString(), fromUser: false, type: 'text', content: text, time: this.nowTime(), cardData: null } as ChatMessage)
},
insertImageMsg() {
this.pushMessage({ id: 'img_' + Date.now().toString(), fromUser: false, type: 'image', content: '', time: this.nowTime(), cardData: null } as ChatMessage)
},
insertProductMsg() {
this.pushMessage({ id: 'prod_' + Date.now().toString(), fromUser: false, type: 'product', content: '', time: this.nowTime(), cardData: { emoji: '🩺', name: '居家护理定制套餐', price: '3200', tag: '可定制频次,支持长护险' } as ProductCardData } as ChatMessage)
},
insertOrderMsg() {
this.pushMessage({ id: 'ord_' + Date.now().toString(), fromUser: false, type: 'order', content: '', time: this.nowTime(), cardData: { orderNo: 'SV' + Date.now().toString().slice(-8), status: '待接单', statusCode: 'pending', amount: '2800.00', itemsDesc: '居家护理基础套餐 × 1月' } as OrderCardData } as ChatMessage)
},
closeSession() {
uni.showModal({
title: '结束会话',
content: '确认结束与该用户的当前咨询会话吗?',
success: (res) => {
if (res.confirm) {
const idx = this.sessions.findIndex((s: Session) => s.id === this.currentSessionId)
if (idx >= 0) this.sessions[idx].status = 'closed'
this.pushMessage({ id: 'sys_' + Date.now().toString(), fromUser: false, type: 'system', content: '会话已由客服结束 · ' + this.nowTime(), time: '', cardData: null } as ChatMessage)
this.activeTab = 0
}
}
})
},
goBack() {
uni.navigateBack()
}
}
}
</script>
<style>
.workbench { display: flex; flex-direction: column; height: 100vh; background-color: #f4f6f9; }
/* 顶部导航 */
.wb-header { height: 88rpx; background-color: #1a6fd4; display: flex; flex-direction: row; align-items: center; padding: 0 24rpx; flex-shrink: 0; }
.wb-back { width: 60rpx; height: 60rpx; display: flex; align-items: center; justify-content: center; }
.wb-back-icon { font-size: 52rpx; color: #fff; font-weight: bold; line-height: 1; }
.wb-header-title { font-size: 34rpx; font-weight: bold; color: #fff; flex: 1; margin-left: 8rpx; }
.wb-header-right { display: flex; flex-direction: row; align-items: center; gap: 16rpx; }
.wb-status-wrap { display: flex; flex-direction: row; align-items: center; background-color: rgba(255,255,255,0.15); border-radius: 20rpx; padding: 6rpx 16rpx; }
.wb-status-dot { width: 14rpx; height: 14rpx; border-radius: 7rpx; background-color: #52c41a; margin-right: 8rpx; }
.wb-status-text { font-size: 22rpx; color: #fff; }
.wb-pending-wrap { background-color: #ff4d4f; border-radius: 20rpx; padding: 6rpx 16rpx; }
.wb-pending-text { font-size: 22rpx; color: #fff; font-weight: bold; }
/* Tab 栏 */
.wb-tabs { display: flex; flex-direction: row; background-color: #fff; border-bottom: 2rpx solid #e8e8e8; flex-shrink: 0; }
.wb-tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; padding: 18rpx 0 14rpx; position: relative; }
.wb-tab-item:active { background-color: #f5f7fa; }
.wb-tab-icon { font-size: 36rpx; }
.wb-tab-label { font-size: 24rpx; color: #888; margin-top: 4rpx; }
.wb-tab-active .wb-tab-label { color: #1a6fd4; font-weight: bold; }
.wb-tab-active { border-bottom: 4rpx solid #1a6fd4; }
.wb-tab-badge { position: absolute; top: 10rpx; right: 16rpx; min-width: 28rpx; height: 28rpx; background-color: #ff4d4f; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; padding: 0 6rpx; }
.wb-tab-badge-num { font-size: 18rpx; color: #fff; font-weight: bold; }
/* 当前会话提示条 */
.session-hint-bar { background-color: #f0f7ff; border-bottom: 1rpx solid #d0e8ff; padding: 12rpx 24rpx; display: flex; flex-direction: row; align-items: center; flex-shrink: 0; }
.session-hint-avatar { font-size: 32rpx; margin-right: 10rpx; }
.session-hint-name { font-size: 24rpx; color: #1a6fd4; font-weight: bold; flex: 1; }
.session-hint-status { border-radius: 8rpx; padding: 2rpx 12rpx; margin-right: 16rpx; }
.shs-pending { background-color: #fff3e0; }
.shs-pending .session-hint-status-text { font-size: 20rpx; color: #ff8c00; }
.shs-active { background-color: #e8f5e9; }
.shs-active .session-hint-status-text { font-size: 20rpx; color: #388e3c; }
.shs-closed { background-color: #f5f5f5; }
.shs-closed .session-hint-status-text { font-size: 20rpx; color: #999; }
.session-hint-switch { background-color: #1a6fd4; border-radius: 20rpx; padding: 6rpx 20rpx; }
.session-hint-switch-text { font-size: 20rpx; color: #fff; }
/* 内容区 */
.wb-content { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
/* ====== 模块一:会话列表 ====== */
.module-sessions { flex: 1; display: flex; flex-direction: column; background-color: #fff; overflow: hidden; }
.sessions-search-wrap { padding: 20rpx 24rpx; background-color: #f8f9fc; border-bottom: 1rpx solid #eee; flex-shrink: 0; }
.sessions-search-input { background-color: #fff; border: 1rpx solid #e0e0e0; border-radius: 40rpx; padding: 14rpx 28rpx; font-size: 28rpx; width: 100%; box-sizing: border-box; }
.sessions-scroll { flex: 1; height: 0; }
.session-card { display: flex; flex-direction: row; align-items: center; padding: 28rpx 24rpx; border-bottom: 1rpx solid #f0f0f0; }
.session-card:active { background-color: #f0f7ff; }
.session-card-active { background-color: #e8f4ff; border-left: 6rpx solid #1a6fd4; }
.sc-avatar-wrap { position: relative; margin-right: 20rpx; flex-shrink: 0; }
.sc-avatar-emoji { font-size: 80rpx; }
.sc-unread-badge { position: absolute; top: -6rpx; right: -6rpx; min-width: 32rpx; height: 32rpx; background-color: #ff4d4f; border-radius: 16rpx; display: flex; align-items: center; justify-content: center; padding: 0 8rpx; border: 2rpx solid #fff; }
.sc-unread-num { font-size: 18rpx; color: #fff; font-weight: bold; }
.sc-info { flex: 1; overflow: hidden; }
.sc-row-top { display: flex; flex-direction: row; justify-content: space-between; align-items: center; margin-bottom: 4rpx; }
.sc-name { font-size: 30rpx; font-weight: bold; color: #1a1a1a; flex: 1; }
.sc-time { font-size: 22rpx; color: #aaa; flex-shrink: 0; margin-left: 16rpx; }
.sc-consult-type { font-size: 22rpx; color: #1a6fd4; display: block; margin-bottom: 8rpx; }
.sc-row-bottom { display: flex; flex-direction: row; align-items: center; }
.sc-preview { font-size: 26rpx; color: #999; flex: 1; }
.sc-preview-bold { color: #333; font-weight: bold; }
.sc-status-tag { border-radius: 8rpx; padding: 4rpx 14rpx; flex-shrink: 0; margin-left: 12rpx; }
.sc-status-pending { background-color: #fff3e0; }
.sc-status-pending .sc-status-text { color: #ff8c00; font-size: 22rpx; }
.sc-status-active { background-color: #e8f5e9; }
.sc-status-active .sc-status-text { color: #388e3c; font-size: 22rpx; }
.sc-status-closed { background-color: #f5f5f5; }
.sc-status-closed .sc-status-text { color: #999; font-size: 22rpx; }
.sc-arrow { font-size: 36rpx; color: #ccc; margin-left: 12rpx; flex-shrink: 0; }
.sessions-empty { padding: 100rpx 0; display: flex; flex-direction: column; align-items: center; }
.sessions-empty-icon { font-size: 80rpx; margin-bottom: 20rpx; }
.sessions-empty-text { font-size: 28rpx; color: #bbb; }
/* ====== 模块二:聊天记录 ====== */
.module-chat { flex: 1; display: flex; flex-direction: column; background-color: #f4f6f9; overflow: hidden; }
.chat-messages-scroll { flex: 1; height: 0; }
.chat-messages-inner { padding: 24rpx 20rpx 20rpx; display: flex; flex-direction: column; }
.msg-row { margin-bottom: 28rpx; }
.msg-row-system { display: flex; justify-content: center; }
.msg-system { background-color: rgba(0,0,0,0.05); border-radius: 10rpx; padding: 10rpx 24rpx; }
.msg-system-text { font-size: 22rpx; color: #999; }
.msg-left { display: flex; flex-direction: row; align-items: flex-start; }
.msg-right { display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end; }
.msg-avatar { font-size: 64rpx; flex-shrink: 0; }
.me-avatar { margin-left: 14rpx; }
.msg-body { margin-left: 14rpx; max-width: 80%; }
.msg-body-right { margin-left: 0; margin-right: 14rpx; display: flex; flex-direction: column; align-items: flex-end; }
.msg-sender { font-size: 22rpx; color: #999; margin-bottom: 8rpx; display: block; }
.bubble { border-radius: 18rpx; padding: 20rpx 24rpx; display: flex; flex-direction: column; }
.bubble-user { background-color: #fff; border-top-left-radius: 4rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); }
.bubble-me { background-color: #d4e8ff; border-top-right-radius: 4rpx; }
.bubble-text { font-size: 30rpx; color: #1a1a1a; line-height: 1.6; }
.bubble-time { font-size: 20rpx; color: #bbb; margin-top: 8rpx; }
.bubble-time-right { text-align: right; }
.bubble-image { flex-direction: row; align-items: center; gap: 12rpx; }
.bubble-image-icon { font-size: 44rpx; }
.bubble-image-hint { font-size: 26rpx; color: #666; }
.msg-card-wrap { display: flex; flex-direction: column; }
.msg-card-right { align-items: flex-end; }
.card-time { font-size: 20rpx; color: #bbb; margin-top: 8rpx; padding: 0 4rpx; }
.card-time-right { text-align: right; }
.product-card { background-color: #fff; border-radius: 16rpx; display: flex; flex-direction: row; align-items: center; padding: 20rpx; min-width: 500rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); border: 1rpx solid #f0f0f0; }
.pc-thumb { width: 96rpx; height: 96rpx; background-color: #f0f7ff; border-radius: 14rpx; display: flex; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
.pc-thumb-emoji { font-size: 56rpx; }
.pc-info { flex: 1; }
.pc-name { font-size: 28rpx; color: #1a1a1a; font-weight: bold; display: block; margin-bottom: 6rpx; }
.pc-price { font-size: 32rpx; color: #e84040; font-weight: bold; display: block; margin-bottom: 4rpx; }
.pc-tag { font-size: 22rpx; color: #888; display: block; }
.order-card { background-color: #fff; border-radius: 16rpx; padding: 20rpx 24rpx; min-width: 500rpx; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); border: 1rpx solid #f0f0f0; }
.oc-row1 { display: flex; flex-direction: row; align-items: center; margin-bottom: 10rpx; }
.oc-label { font-size: 20rpx; color: #999; background-color: #f5f5f5; border-radius: 4rpx; padding: 2rpx 10rpx; margin-right: 12rpx; flex-shrink: 0; }
.oc-no { font-size: 22rpx; color: #555; flex: 1; }
.oc-status-tag { border-radius: 6rpx; padding: 4rpx 14rpx; flex-shrink: 0; }
.oc-pending { background-color: #fff3e0; }
.oc-pending .oc-status-text { color: #ff8c00; font-size: 22rpx; }
.oc-active { background-color: #e8f5e9; }
.oc-active .oc-status-text { color: #388e3c; font-size: 22rpx; }
.oc-done { background-color: #f5f5f5; }
.oc-done .oc-status-text { color: #888; font-size: 22rpx; }
.oc-amount { font-size: 36rpx; color: #e84040; font-weight: bold; display: block; margin-bottom: 4rpx; }
.oc-items { font-size: 24rpx; color: #888; display: block; }
.chat-input-area { background-color: #fff; border-top: 1rpx solid #e8e8e8; flex-shrink: 0; }
.quick-reply-panel { background-color: #f8f9fc; border-bottom: 1rpx solid #eee; padding: 16rpx 20rpx; }
.qr-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-bottom: 14rpx; }
.qr-title { font-size: 24rpx; color: #555; font-weight: bold; }
.qr-close { font-size: 28rpx; color: #aaa; padding: 8rpx; }
.qr-scroll { max-height: 240rpx; }
.qr-item { background-color: #fff; border-radius: 10rpx; padding: 14rpx 20rpx; margin-bottom: 10rpx; border: 1rpx solid #e8e8e8; }
.qr-item:active { background-color: #e8f4ff; }
.qr-text { font-size: 26rpx; color: #333; line-height: 1.5; }
.chat-toolbar { display: flex; flex-direction: row; padding: 16rpx 20rpx 8rpx; gap: 20rpx; border-bottom: 1rpx solid #f0f0f0; }
.toolbar-btn { display: flex; flex-direction: column; align-items: center; padding: 10rpx 24rpx; border-radius: 12rpx; background-color: #f5f7fa; }
.toolbar-btn:active { background-color: #e0ecff; }
.toolbar-btn-active { background-color: #e0ecff; }
.toolbar-icon { font-size: 38rpx; }
.toolbar-label { font-size: 20rpx; color: #666; margin-top: 4rpx; }
.input-row { display: flex; flex-direction: row; align-items: flex-end; padding: 16rpx 20rpx 20rpx; }
.chat-textarea { flex: 1; background-color: #f5f7fa; border-radius: 12rpx; padding: 14rpx 18rpx; font-size: 28rpx; color: #1a1a1a; min-height: 80rpx; max-height: 160rpx; margin-right: 16rpx; border: 1rpx solid #e0e0e0; box-sizing: border-box; }
.send-btn { padding: 20rpx 36rpx; border-radius: 12rpx; background-color: #d0d0d0; flex-shrink: 0; }
.send-btn-active { background-color: #1a6fd4; }
.send-btn-text { font-size: 28rpx; color: #fff; font-weight: bold; }
/* ====== 模块三:用户资料 ====== */
.module-info { flex: 1; height: 0; overflow-y: auto; background-color: #f4f6f9; padding-bottom: 60rpx; }
.info-user-card { background-color: #1a6fd4; padding: 40rpx 30rpx 36rpx; display: flex; flex-direction: row; align-items: center; }
.info-big-avatar { font-size: 100rpx; margin-right: 24rpx; flex-shrink: 0; }
.info-user-main { flex: 1; }
.info-user-name { font-size: 36rpx; font-weight: bold; color: #fff; display: block; margin-bottom: 12rpx; }
.info-tags-row { display: flex; flex-direction: row; flex-wrap: wrap; gap: 12rpx; }
.info-tag { background-color: rgba(255,255,255,0.2); border-radius: 20rpx; padding: 4rpx 18rpx; border: 1rpx solid rgba(255,255,255,0.4); }
.info-tag-text { font-size: 22rpx; color: #fff; }
.info-section { background-color: #fff; margin-top: 16rpx; padding: 24rpx 28rpx; }
.info-section-header { display: flex; flex-direction: row; align-items: center; margin-bottom: 20rpx; }
.info-section-icon { font-size: 32rpx; margin-right: 10rpx; }
.info-section-title { font-size: 28rpx; font-weight: bold; color: #333; }
.info-kv { display: flex; flex-direction: row; align-items: center; padding: 14rpx 0; border-bottom: 1rpx solid #f5f5f5; }
.info-kv:last-child { border-bottom: none; }
.info-key { font-size: 26rpx; color: #999; width: 160rpx; flex-shrink: 0; }
.info-val { font-size: 26rpx; color: #333; }
.info-val-red { color: #e84040; font-weight: bold; }
.consult-product { display: flex; flex-direction: row; align-items: center; background-color: #f0f7ff; border-radius: 14rpx; padding: 20rpx; }
.cp-big-emoji { font-size: 72rpx; margin-right: 20rpx; flex-shrink: 0; }
.cp-detail { flex: 1; }
.cp-pname { font-size: 28rpx; color: #1a1a1a; font-weight: bold; display: block; margin-bottom: 8rpx; }
.cp-pprice { font-size: 36rpx; color: #e84040; font-weight: bold; display: block; }
.recent-row { display: flex; flex-direction: row; align-items: center; padding: 16rpx 0; border-bottom: 1rpx solid #f5f5f5; }
.recent-row:last-child { border-bottom: none; }
.recent-emoji { font-size: 52rpx; margin-right: 18rpx; flex-shrink: 0; }
.recent-detail { flex: 1; }
.recent-name { font-size: 26rpx; color: #333; display: block; margin-bottom: 4rpx; }
.recent-price { font-size: 24rpx; color: #e84040; display: block; }
.order-mini-card { background-color: #fafafa; border-radius: 12rpx; padding: 18rpx 20rpx; margin-bottom: 16rpx; border: 1rpx solid #f0f0f0; }
.order-mini-card:last-child { margin-bottom: 0; }
.omc-row1 { display: flex; flex-direction: row; align-items: center; margin-bottom: 10rpx; }
.omc-no { font-size: 22rpx; color: #888; flex: 1; }
.omc-status-tag { border-radius: 8rpx; padding: 4rpx 14rpx; }
.omc-done { background-color: #f5f5f5; }
.omc-done .omc-status-text { color: #888; font-size: 22rpx; }
.omc-active { background-color: #e8f5e9; }
.omc-active .omc-status-text { color: #388e3c; font-size: 22rpx; }
.omc-pending { background-color: #fff3e0; }
.omc-pending .omc-status-text { color: #ff8c00; font-size: 22rpx; }
.omc-row2 { display: flex; flex-direction: row; justify-content: space-between; align-items: center; }
.omc-items { font-size: 24rpx; color: #666; flex: 1; }
.omc-amount { font-size: 28rpx; color: #e84040; font-weight: bold; flex-shrink: 0; margin-left: 16rpx; }
.info-actions { margin: 24rpx 28rpx 0; display: flex; flex-direction: row; gap: 20rpx; }
.info-action-btn { flex: 1; padding: 24rpx 0; border-radius: 14rpx; background-color: #f0f0f0; display: flex; align-items: center; justify-content: center; }
.info-action-btn.primary { background-color: #1a6fd4; }
.info-action-text { font-size: 28rpx; color: #555; font-weight: bold; }
.info-action-btn.primary .info-action-text { color: #fff; }
</style>