新增订单群聊

This commit is contained in:
yml
2026-05-25 14:57:19 +08:00
parent 1a3886f8d1
commit 7e7e6dc166
18 changed files with 2152 additions and 225 deletions
+108
View File
@@ -0,0 +1,108 @@
import { apiClient } from './client'
import type { ApiResponse, PaginatedResult } from './types'
export interface ChatParticipant {
id: number
conversation_id: number
participant_type: 'user' | 'admin'
participant_id: number
role: 'renter' | 'owner' | 'support'
display_name: string
avatar_url: string
last_read_at?: string
joined_at: string
}
export interface ChatConversation {
id: number
order_id: number
type: string
title: string
status: string
role: 'renter' | 'owner' | 'support'
participants?: ChatParticipant[]
last_message_id?: number
last_message_preview: string
last_message_at?: string
unread_count: number
created_at: string
updated_at: string
}
export interface ChatMessage {
id: number
conversation_id: number
sender_type: 'user' | 'admin' | 'system'
sender_id: number
sender_role: 'renter' | 'owner' | 'support' | 'system'
sender_name: string
sender_avatar: string
is_self: boolean
content_type: 'text' | 'system'
content: string
attachment_urls: string[]
created_at: string
}
export async function fetchChats(page = 1, pageSize = 20) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/chats', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/chats/${id}`)
return data.data
}
export async function fetchOrderChat(orderId: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/orders/${orderId}/chat`)
return data.data
}
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/chats/${id}/messages`, { content })
return data.data
}
export async function markChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/chats/${id}/read`)
return data.data
}
export async function fetchAdminChats(page = 1, pageSize = 50) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>('/admin/chats', {
params: { page, page_size: pageSize },
})
return data.data
}
export async function fetchAdminChat(id: number) {
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
return data.data
}
export async function fetchAdminChatMessages(id: number, page = 1, pageSize = 100) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/admin/chats/${id}/messages`, {
params: { page, page_size: pageSize },
})
return data.data
}
export async function sendAdminChatMessage(id: number, content: string) {
const { data } = await apiClient.post<ApiResponse<ChatMessage>>(`/admin/chats/${id}/messages`, { content })
return data.data
}
export async function markAdminChatRead(id: number) {
const { data } = await apiClient.post<ApiResponse<{ read: boolean }>>(`/admin/chats/${id}/read`)
return data.data
}
+2 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
import { ChatDotRound, DataLine, Document, DocumentChecked, Operation, ScaleToOriginal, Shop, SwitchButton, Tickets, User, Wallet } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -17,6 +17,7 @@ const navItems = [
{ label: '商品管理', to: '/admin/listings', icon: Shop },
{ label: '商品审核', to: '/admin/listings/review', icon: DocumentChecked },
{ label: '仲裁中心', to: '/admin/disputes', icon: ScaleToOriginal },
{ label: '客服群聊', to: '/admin/chats', icon: ChatDotRound },
{ label: '资金流水', to: '/admin/wallet-ledger', icon: Wallet },
{ label: '系统配置', to: '/admin/system-configs', icon: Operation },
{ label: '审计日志', to: '/admin/audit-logs', icon: Document },
+6
View File
@@ -58,6 +58,12 @@ export const adminRoutes: RouteRecordRaw[] = [
component: () => import('@/views/admin/AdminDisputesView.vue'),
meta: adminMeta,
},
{
path: '/admin/chats',
name: 'admin-chats',
component: () => import('@/views/admin/AdminChatsView.vue'),
meta: adminMeta,
},
{
path: '/admin/wallet-ledger',
name: 'admin-wallet-ledger',
+6
View File
@@ -37,6 +37,12 @@ export const mobileRoutes: RouteRecordRaw[] = [
component: () => import('@/views/mobile/MobileMessagesView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{
path: '/m/chats/:id',
name: 'mobile-chat',
component: () => import('@/views/mobile/MobileChatView.vue'),
meta: { layout: 'blank', requiresAuth: true },
},
{
path: '/m/realname',
name: 'mobile-realname',
+377
View File
@@ -0,0 +1,377 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
markAdminChatRead,
sendAdminChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const conversations = ref<ChatConversation[]>([])
const active = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const messageLoading = ref(false)
const sending = ref(false)
const content = ref('')
const listRef = ref<HTMLElement | null>(null)
let timer: number | undefined
const activeMembers = computed(() => {
const participants = active.value?.participants || []
return participants.map(item => `${roleLabel(item.role)}${item.display_name}`).join(' / ')
})
onMounted(async () => {
await loadConversations()
timer = window.setInterval(async () => {
await loadConversations(false)
if (active.value) await loadMessages(active.value.id, false)
}, 5000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100)
conversations.value = res.items
const first = conversations.value[0]
if (!active.value && first) {
await openConversation(first)
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.value = false
}
}
async function openConversation(item: ChatConversation) {
messageLoading.value = true
try {
active.value = await fetchAdminChat(item.id)
await loadMessages(item.id)
await markAdminChatRead(item.id)
await loadConversations(false)
} catch {
ElMessage.error('会话详情加载失败')
} finally {
messageLoading.value = false
}
}
async function loadMessages(id: number, scroll = true) {
const res = await fetchAdminChatMessages(id, 1, 100)
messages.value = res.items
if (scroll) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
if (!active.value || !text || sending.value) return
sending.value = true
try {
const message = await sendAdminChatMessage(active.value.id, text)
messages.value = [...messages.value, message]
content.value = ''
await nextTick()
scrollBottom()
await loadConversations(false)
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(item: ChatMessage) {
if (item.sender_type === 'system') return '系统'
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
}
</script>
<template>
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服群聊</h1>
<p>处理订单三方沟通</p>
</div>
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
</div>
<div class="chat-workbench">
<aside class="conversation-pane" v-loading="loading">
<button
v-for="item in conversations"
:key="item.id"
type="button"
class="conversation-row"
:class="{ active: active?.id === item.id }"
@click="openConversation(item)"
>
<div class="row-title">
<strong>{{ item.title }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
</button>
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
</aside>
<main class="message-pane">
<template v-if="active">
<header class="message-head">
<div>
<h2>{{ active.title }}</h2>
<p>{{ activeMembers }}</p>
</div>
<RouterLink :to="`/admin/orders/${active.order_id}`">查看订单</RouterLink>
</header>
<div ref="listRef" class="message-list" v-loading="messageLoading">
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span>{{ item.content }}</span>
</template>
<template v-else>
<small>{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}</small>
<p>{{ item.content }}</p>
</template>
</div>
</div>
<footer class="composer">
<el-input
v-model="content"
type="textarea"
:rows="3"
maxlength="1000"
show-word-limit
placeholder="输入客服回复"
@keydown.enter.exact.prevent="handleSend"
/>
<el-button type="primary" :loading="sending" :disabled="!content.trim()" @click="handleSend">发送</el-button>
</footer>
</template>
<el-empty v-else description="请选择会话" />
</main>
</div>
</section>
</template>
<style scoped>
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.chat-workbench {
display: grid;
min-height: 640px;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-row em {
position: absolute;
right: 12px;
bottom: 12px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.message-head h2 {
margin: 0;
font-size: 18px;
}
.message-head p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.message-list {
overflow-y: auto;
padding: 18px;
background: #f3f6fa;
}
.message-row {
max-width: 70%;
margin-bottom: 14px;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
border-top: 1px solid #e5e7eb;
}
</style>
@@ -0,0 +1,338 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showToast } from 'vant'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const route = useRoute()
const router = useRouter()
const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const content = ref('')
const listRef = ref<HTMLElement | null>(null)
let timer: number | undefined
const conversationID = computed(() => Number(route.params.id || 0))
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
onMounted(async () => {
await loadAll()
timer = window.setInterval(() => {
loadMessages(false)
}, 5000)
})
onBeforeUnmount(() => {
if (timer) window.clearInterval(timer)
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(false),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
showToast({ message: '加载会话失败', icon: 'cross' })
} finally {
loading.value = false
}
}
async function loadMessages(scrollToBottom = true) {
if (!conversationID.value) return
const res = await fetchChatMessages(conversationID.value, 1, 100)
messages.value = res.items
if (scrollToBottom) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
if (!text || sending.value) return
sending.value = true
try {
const message = await sendChatMessage(conversationID.value, text)
content.value = ''
messages.value = [...messages.value, message]
await markChatRead(conversationID.value)
await nextTick()
scrollBottom()
} catch {
showToast({ message: '发送失败', icon: 'cross' })
} finally {
sending.value = false
}
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
</script>
<template>
<main class="mobile-chat">
<header class="chat-header">
<button class="icon-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<div class="chat-title">
<h1>{{ conversation?.title || '订单群聊' }}</h1>
<p>{{ memberText }}</p>
</div>
<button class="icon-btn" type="button" @click="conversation && router.push(`/m/orders/${conversation.order_id}`)">
<van-icon name="orders-o" :size="20" />
</button>
</header>
<section ref="listRef" class="message-list" :class="{ loading }">
<van-loading v-if="loading && messages.length === 0" class="loading-state" />
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
</template>
<template v-else>
<div class="avatar">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div class="bubble">{{ item.content }}</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</section>
<footer class="composer">
<van-field
v-model="content"
class="composer-input"
type="textarea"
autosize
:maxlength="1000"
rows="1"
placeholder="发送消息"
@keydown.enter.prevent="handleSend"
/>
<button class="send-btn" type="button" :disabled="!content.trim() || sending" @click="handleSend">
<van-icon name="guide-o" :size="20" />
</button>
</footer>
</main>
</template>
<style scoped>
.mobile-chat {
display: grid;
grid-template-rows: 56px minmax(0, 1fr) auto;
height: 100dvh;
background: #f3f6fa;
}
.chat-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) 44px;
align-items: center;
border-bottom: 1px solid #e7ecf2;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(10px);
}
.icon-btn {
display: grid;
width: 44px;
height: 44px;
place-items: center;
border: 0;
background: transparent;
color: #374151;
}
.chat-title {
min-width: 0;
text-align: center;
}
.chat-title h1 {
margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 3px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-list {
min-height: 0;
overflow-y: auto;
padding: 14px 12px 18px;
}
.loading-state {
display: block;
margin: 70px auto;
}
.message-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 13px;
font-weight: 800;
}
.message-row.self .avatar {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: min(76vw, 330px);
flex-direction: column;
align-items: flex-start;
}
.message-row.self .bubble-wrap {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 11px;
}
.bubble {
max-width: 100%;
padding: 9px 11px;
border-radius: 8px;
background: #fff;
color: #111827;
font-size: 14px;
line-height: 1.45;
word-break: break-word;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05);
}
.message-row.self .bubble {
background: #dff5eb;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 10px;
}
.system-message {
max-width: 82%;
padding: 5px 9px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
.composer {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
align-items: end;
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
border-top: 1px solid #e7ecf2;
background: #fff;
}
.composer-input {
border: 1px solid #d9e0e8;
border-radius: 8px;
overflow: hidden;
}
.send-btn {
display: grid;
width: 40px;
height: 40px;
place-items: center;
border: 0;
border-radius: 8px;
background: #1477ff;
color: #fff;
}
.send-btn:disabled {
background: #c8d1dd;
}
</style>
+185 -219
View File
@@ -1,47 +1,41 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import MobileBottomNav from '@/components/MobileBottomNav.vue'
import { fetchNotifications, markNotificationRead, type NotificationItem } from '@/api/notifications'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const refreshing = ref(false)
const finished = ref(false)
const notifications = ref<NotificationItem[]>([])
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 15
const pageSize = 20
const total = ref(0)
onMounted(() => {
onRefresh()
})
async function loadNotifications(isRefresh = false) {
async function loadChats(isRefresh = false) {
if (isRefresh) {
page.value = 1
finished.value = false
}
loading.value = true
try {
const res = await fetchNotifications(page.value, pageSize)
if (isRefresh) {
notifications.value = res.items
} else {
notifications.value = [...notifications.value, ...res.items]
}
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (notifications.value.length >= res.total || res.items.length === 0) {
if (conversations.value.length >= res.total || res.items.length === 0) {
finished.value = true
} else {
page.value += 1
}
} catch {
showToast({ message: '获取消息失败', icon: 'cross' })
showToast({ message: '获取会话失败', icon: 'cross' })
finished.value = true
} finally {
loading.value = false
@@ -51,136 +45,89 @@ async function loadNotifications(isRefresh = false) {
function onRefresh() {
refreshing.value = true
loadNotifications(true)
loadChats(true)
}
function onLoad() {
if (loading.value || finished.value) return
loadNotifications(false)
loadChats(false)
}
async function handleMarkRead(id: number) {
try {
await markNotificationRead(id)
const index = notifications.value.findIndex(item => item.id === id)
if (index !== -1) {
// 局部更新状态,避免整站刷新
const item = notifications.value[index]
if (item) {
item.read_at = new Date().toISOString()
}
}
} catch {
showToast({ message: '标记已读失败', icon: 'cross' })
function openConversation(item: ChatConversation) {
router.push(`/m/chats/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
}
return map[role] || '成员'
}
// 全部已读逻辑
const hasUnread = computed(() => notifications.value.some(item => !item.read_at))
async function markAllRead() {
const unreadItems = notifications.value.filter(item => !item.read_at)
if (unreadItems.length === 0) return
showToast({
type: 'loading',
message: '处理中...',
forbidClick: true,
duration: 0
})
try {
await Promise.all(unreadItems.map(item => markNotificationRead(item.id)))
showToast({ message: '已全部标记为已读', icon: 'passed' })
onRefresh()
} catch {
showToast({ message: '操作失败,请重试', icon: 'cross' })
}
function previewText(item: ChatConversation) {
return item.last_message_preview || '订单群聊已创建'
}
function getNotificationBadge(type: string) {
const map: Record<string, { text: string; class: string }> = {
listing_review: { text: '上架审核', class: 'badge-review' },
listing_admin: { text: '后台管控', class: 'badge-admin' },
order_state: { text: '订单状态', class: 'badge-order' },
dispute_state: { text: '纠纷仲裁', class: 'badge-dispute' }
}
return map[type] || { text: '通知', class: 'badge-system' }
}
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
</script>
<template>
<main class="mobile-messages">
<!-- 顶部导航栏 -->
<header class="page-header">
<button class="back-btn" @click="router.back()">
<button class="back-btn" type="button" @click="router.back()">
<van-icon name="arrow-left" :size="20" />
</button>
<h1>消息中心</h1>
<button v-if="hasUnread" class="header-action-btn" @click="markAllRead">
全部已读
</button>
<span v-else class="header-spacer"></span>
<h1>消息</h1>
<span class="header-count">{{ unreadTotal > 0 ? `${unreadTotal} 未读` : '' }}</span>
</header>
<!-- 下拉刷新 + 上拉加载列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh" class="scroll-container">
<van-pull-refresh v-model="refreshing" class="scroll-container" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多消息"
@load="onLoad"
finished-text="没有更多会话"
:immediate-check="false"
@load="onLoad"
>
<!-- 空状态 -->
<van-empty
v-if="!loading && notifications.length === 0"
description="暂无消息记录"
v-if="!loading && conversations.length === 0"
description="暂无订单群聊"
class="empty-state"
/>
<div v-else class="messages-list">
<div
v-for="item in notifications"
<div v-else class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="message-card"
:class="{ unread: !item.read_at }"
@click="!item.read_at && handleMarkRead(item.id)"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<!-- 头部类型与未读红点 -->
<div class="card-header-row">
<span class="type-badge" :class="getNotificationBadge(item.type).class">
{{ getNotificationBadge(item.type).text }}
</span>
<span class="time-label">{{ formatDateMinute(item.created_at) }}</span>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<!-- 消息主体 -->
<h3 class="message-title">
<span v-if="!item.read_at" class="unread-dot"></span>
{{ item.title }}
</h3>
<p class="message-content">{{ item.content }}</p>
<!-- 操作按钮 -->
<div class="card-actions" v-if="item.biz_type === 'order' && item.biz_id">
<van-button
size="mini"
type="primary"
plain
round
class="action-btn"
@click.stop="router.push(`/m/orders/${item.biz_id}`)"
>
查看订单
</van-button>
<div class="conversation-main">
<div class="conversation-title-row">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span>订单 #{{ item.order_id }}</span>
</div>
<p>{{ previewText(item) }}</p>
</div>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
</van-list>
</van-pull-refresh>
<!-- 底部导航 -->
<MobileBottomNav />
</main>
</template>
@@ -188,163 +135,182 @@ function getNotificationBadge(type: string) {
<style scoped>
.mobile-messages {
min-height: 100dvh;
background: #f6f8fa;
padding-bottom: calc(64px + env(safe-area-inset-bottom));
background: #f5f7fb;
padding-bottom: calc(62px + env(safe-area-inset-bottom));
}
/* ========== 顶部导航 ========== */
.page-header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
display: grid;
grid-template-columns: 44px 1fr 72px;
align-items: center;
justify-content: space-between;
height: 48px;
padding: 0 12px;
background: rgba(255, 255, 255, 0.94);
padding: 0 8px;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f5;
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(243, 244, 246, 0.8);
}
.page-header h1 {
flex: 1;
margin: 0;
font-size: 17px;
font-weight: 700;
color: #111827;
font-size: 17px;
font-weight: 800;
text-align: center;
}
.back-btn {
display: grid;
width: 36px;
height: 36px;
width: 40px;
height: 40px;
place-items: center;
border: none;
background: none;
border: 0;
background: transparent;
color: #374151;
cursor: pointer;
}
.header-action-btn {
border: none;
background: none;
color: #1477ff;
font-size: 13px;
.header-count {
color: #ef4444;
font-size: 12px;
font-weight: 700;
cursor: pointer;
padding: 0 8px;
white-space: nowrap;
text-align: right;
}
.header-spacer {
width: 36px;
}
/* ========== 列表内容 ========== */
.scroll-container {
min-height: calc(100dvh - 48px - 64px - env(safe-area-inset-bottom));
min-height: calc(100dvh - 48px - 62px - env(safe-area-inset-bottom));
}
.empty-state {
padding: 80px 0;
padding-top: 90px;
}
.messages-list {
padding: 14px 16px;
.conversation-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
padding: 12px;
}
/* ========== 消息卡片 ========== */
.message-card {
background: #ffffff;
border-radius: 16px;
padding: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02), 0 1px 4px rgba(0, 0, 0, 0.02);
border: 1px solid rgba(243, 244, 246, 0.9);
transition: transform 0.15s ease, background 0.15s ease;
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 10px;
width: 100%;
padding: 12px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
text-align: left;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
}
.conversation-item:active {
transform: scale(0.99);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-main {
min-width: 0;
}
.conversation-title-row {
display: flex;
flex-direction: column;
gap: 6px;
}
.message-card:active {
transform: scale(0.98);
}
/* 未读态加点微弱阴影背景 */
.message-card.unread {
border-color: rgba(20, 119, 255, 0.15);
background: linear-gradient(135deg, #ffffff 0%, #fafcff 100%);
}
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.type-badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 6px;
.conversation-title-row h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #111827;
font-size: 15px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 11px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
color: #6b7280;
font-size: 11px;
}
.role-chip {
padding: 1px 6px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-weight: 700;
}
/* 消息类型徽章颜色 */
.badge-review { color: #f59e0b; background: rgba(245, 158, 11, 0.08); }
.badge-admin { color: #ef4444; background: rgba(239, 68, 68, 0.08); }
.badge-order { color: #1477ff; background: rgba(20, 119, 255, 0.08); }
.badge-dispute { color: #8b5cf6; background: rgba(139, 92, 246, 0.08); }
.badge-system { color: #6b7280; background: rgba(107, 114, 128, 0.08); }
.time-label {
font-size: 10px;
color: #9ca3af;
}
.message-title {
margin: 2px 0 0;
font-size: 14px;
font-weight: 800;
color: #111827;
display: flex;
align-items: center;
gap: 6px;
}
.unread-dot {
width: 6px;
height: 6px;
background-color: #ef4444;
border-radius: 50%;
display: inline-block;
}
.message-content {
margin: 0;
font-size: 12px;
.conversation-main p {
margin: 7px 0 0;
overflow: hidden;
color: #4b5563;
line-height: 1.5;
word-break: break-all;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
justify-content: flex-end;
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid #f3f4f6;
}
.action-btn {
height: 24px !important;
padding: 0 10px !important;
font-size: 11px !important;
font-weight: 700 !important;
.unread-badge {
position: absolute;
right: 10px;
bottom: 10px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 18px;
text-align: center;
}
</style>
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { showToast, showDialog } from "vant";
import { fetchOrderChat } from "@/api/chats";
import { createDispute } from "@/api/disputes";
import { uploadFile } from "@/api/files";
import {
@@ -38,6 +39,7 @@ const acceptingCheckout = ref(false);
const rejectingCheckout = ref(false);
const disputing = ref(false);
const uploadingEvidence = ref(false);
const openingChat = ref(false);
const order = ref<Order | null>(null);
const handoffRecords = ref<HandoffRecord[]>([]);
const handoffContent = ref("");
@@ -506,6 +508,19 @@ function linesToList(value: string) {
.filter(Boolean);
}
async function openOrderChat() {
if (!order.value || openingChat.value) return;
openingChat.value = true;
try {
const chat = await fetchOrderChat(order.value.id);
router.push(`/m/chats/${chat.id}`);
} catch {
showToast({ message: "订单群聊暂不可用", icon: "warning-o" });
} finally {
openingChat.value = false;
}
}
/* Status Theme Color Mapping */
function getStatusTagType(status: string) {
if (["completed", "received"].includes(status)) return "success";
@@ -523,7 +538,10 @@ function getStatusTagType(status: string) {
<van-icon name="arrow-left" :size="20" />
</button>
<h1>订单详情</h1>
<span class="header-spacer"></span>
<button v-if="order" class="chat-btn" type="button" :disabled="openingChat" @click="openOrderChat">
<van-icon name="chat-o" :size="19" />
</button>
<span v-else class="header-spacer"></span>
</header>
<van-loading v-if="loading" class="center-loading" size="24px" vertical>
@@ -1070,7 +1088,8 @@ function getStatusTagType(status: string) {
text-align: center;
}
.back-btn {
.back-btn,
.chat-btn {
display: grid;
width: 36px;
height: 36px;
@@ -1081,6 +1100,10 @@ function getStatusTagType(status: string) {
cursor: pointer;
}
.chat-btn:disabled {
color: #a1a1aa;
}
.header-spacer {
width: 36px;
}