用户消息系统:消息列表、订单群聊、联系对方入口
- 新增消息列表页和聊天详情页,支持 SSE 实时推送 - 订单详情页新增"联系对方"按钮,一键进入订单群聊 - 导航栏新增"消息"入口 - 修复 mobileHost 路径匹配 bug,避免 /m 前缀误匹配 - 优化 dev.sh:提取 mysql 辅助函数、修复空 PID 处理、条件检查依赖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { ref } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
ChatDotRound,
|
||||||
CirclePlus,
|
CirclePlus,
|
||||||
House,
|
House,
|
||||||
Search,
|
Search,
|
||||||
@@ -21,6 +22,7 @@ const navItems = [
|
|||||||
{ label: "首页", to: "/", icon: House },
|
{ label: "首页", to: "/", icon: House },
|
||||||
{ label: "租号大厅", to: "/listings", icon: Shop },
|
{ label: "租号大厅", to: "/listings", icon: Shop },
|
||||||
{ label: "我的订单", to: "/orders", icon: Tickets },
|
{ label: "我的订单", to: "/orders", icon: Tickets },
|
||||||
|
{ label: "消息", to: "/messages", icon: ChatDotRound },
|
||||||
{ label: "钱包", to: "/wallet", icon: Wallet },
|
{ label: "钱包", to: "/wallet", icon: Wallet },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -42,4 +42,16 @@ export const accountRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/account/RealnameView.vue'),
|
component: () => import('@/views/account/RealnameView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/messages',
|
||||||
|
name: 'messages',
|
||||||
|
component: () => import('@/views/account/MessagesView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/messages/:id',
|
||||||
|
name: 'chat',
|
||||||
|
component: () => import('@/views/account/ChatView.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function getMobilePath(path: string): string {
|
|||||||
if (path === "/" || path === "") {
|
if (path === "/" || path === "") {
|
||||||
return "/m";
|
return "/m";
|
||||||
}
|
}
|
||||||
if (path.startsWith("/m") || path.startsWith("/admin")) {
|
if (path === "/m" || path.startsWith("/m/") || path.startsWith("/admin")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +31,9 @@ export function getMobilePath(path: string): string {
|
|||||||
if (path === "/seller/listings/create") {
|
if (path === "/seller/listings/create") {
|
||||||
return "/m/seller/listings/create";
|
return "/m/seller/listings/create";
|
||||||
}
|
}
|
||||||
|
if (path === "/messages" || path.startsWith("/messages/")) {
|
||||||
|
return "/m/messages";
|
||||||
|
}
|
||||||
|
|
||||||
// PC-only views that don't have mobile counterparts go to mobile profile as fallback
|
// PC-only views that don't have mobile counterparts go to mobile profile as fallback
|
||||||
if (path === "/wallet" || path === "/notifications" || path.startsWith("/seller")) {
|
if (path === "/wallet" || path === "/notifications" || path.startsWith("/seller")) {
|
||||||
@@ -41,7 +44,7 @@ export function getMobilePath(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getPcPath(path: string): string {
|
export function getPcPath(path: string): string {
|
||||||
if (!path.startsWith("/m") || path.startsWith("/admin")) {
|
if ((path !== "/m" && !path.startsWith("/m/")) || path.startsWith("/admin")) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +65,8 @@ export function getPcPath(path: string): string {
|
|||||||
if (subPath === "/realname") return "/realname";
|
if (subPath === "/realname") return "/realname";
|
||||||
if (subPath === "/seller/listings/create") return "/seller/listings/create";
|
if (subPath === "/seller/listings/create") return "/seller/listings/create";
|
||||||
|
|
||||||
if (subPath === "/messages") return "/";
|
if (subPath === "/messages") return "/messages";
|
||||||
|
if (subPath.startsWith("/chats/")) return "/messages/" + subPath.substring(7);
|
||||||
if (subPath === "/profile") return "/wallet";
|
if (subPath === "/profile") return "/wallet";
|
||||||
|
|
||||||
return subPath || "/";
|
return subPath || "/";
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
fetchChat,
|
||||||
|
fetchChatMessages,
|
||||||
|
markChatRead,
|
||||||
|
sendChatMessage,
|
||||||
|
type ChatConversation,
|
||||||
|
type ChatMessage,
|
||||||
|
} from '@/api/chats'
|
||||||
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
|
const currentUserId = Number(localStorage.getItem('user_id') || 0)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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(' · ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
|
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
|
||||||
|
const msg = event.message
|
||||||
|
if (msg) appendMessage({
|
||||||
|
id: msg.id,
|
||||||
|
conversation_id: msg.conversation_id,
|
||||||
|
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
||||||
|
sender_id: msg.sender_id,
|
||||||
|
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||||||
|
sender_name: msg.sender_name,
|
||||||
|
sender_avatar: '',
|
||||||
|
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
|
||||||
|
content_type: msg.content_type as ChatMessage['content_type'],
|
||||||
|
content: msg.content,
|
||||||
|
attachment_urls: msg.attachment_urls || [],
|
||||||
|
created_at: msg.created_at,
|
||||||
|
})
|
||||||
|
markChatRead(conversationID.value).catch(() => {})
|
||||||
|
}
|
||||||
|
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
|
||||||
|
loadConversation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||||
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadAll()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(conversationID, async (id, oldId) => {
|
||||||
|
if (id && id !== oldId) {
|
||||||
|
await loadAll()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
if (!conversationID.value) return
|
||||||
|
loading.value = true
|
||||||
|
conversation.value = null
|
||||||
|
messages.value = []
|
||||||
|
try {
|
||||||
|
const [chat] = await Promise.all([
|
||||||
|
fetchChat(conversationID.value),
|
||||||
|
loadMessages(true),
|
||||||
|
])
|
||||||
|
conversation.value = chat
|
||||||
|
await markChatRead(conversationID.value)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载会话失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConversation() {
|
||||||
|
if (!conversationID.value) return
|
||||||
|
try {
|
||||||
|
conversation.value = await fetchChat(conversationID.value)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 sent = await sendChatMessage(conversationID.value, text)
|
||||||
|
appendMessage(sent)
|
||||||
|
content.value = ''
|
||||||
|
await loadConversation()
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('发送失败')
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(message: ChatMessage) {
|
||||||
|
if (messages.value.some(item => item.id === message.id)) return
|
||||||
|
messages.value = [...messages.value, message]
|
||||||
|
nextTick(() => scrollBottom())
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page chat-page">
|
||||||
|
<div class="chat-workbench">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="chat-header">
|
||||||
|
<button class="back-btn" type="button" @click="router.push('/messages')">
|
||||||
|
<el-icon :size="18"><ArrowLeft /></el-icon>
|
||||||
|
<span>返回</span>
|
||||||
|
</button>
|
||||||
|
<div class="chat-title">
|
||||||
|
<h2>{{ conversation?.title || '订单群聊' }}</h2>
|
||||||
|
<p>{{ memberText }}</p>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
v-if="conversation?.order_id"
|
||||||
|
type="primary"
|
||||||
|
link
|
||||||
|
@click="router.push(`/orders/${conversation.order_id}`)"
|
||||||
|
>
|
||||||
|
查看订单
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages -->
|
||||||
|
<div ref="listRef" v-loading="loading" class="message-list">
|
||||||
|
<div v-if="loading && messages.length === 0" class="loading-placeholder">
|
||||||
|
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else-if="!loading && messages.length === 0" description="暂无消息" />
|
||||||
|
|
||||||
|
<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" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
|
||||||
|
<div class="bubble-wrap" :class="{ self: item.is_self }">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Composer -->
|
||||||
|
<div class="composer">
|
||||||
|
<el-input
|
||||||
|
v-model="content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
:maxlength="1000"
|
||||||
|
show-word-limit
|
||||||
|
placeholder="发送消息..."
|
||||||
|
resize="none"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<div class="composer-actions">
|
||||||
|
<span class="composer-hint">Enter 发送,Shift+Enter 换行</span>
|
||||||
|
<el-button type="primary" :disabled="!content.trim() || sending" :loading="sending" @click="handleSend">
|
||||||
|
发送
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-page {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-workbench {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 56px - 40px);
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #eef1f5;
|
||||||
|
background: #fafbfc;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: transparent;
|
||||||
|
color: #5a6577;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #f0f5ff;
|
||||||
|
color: #1477ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title h2 {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-title p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: #6b7785;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-placeholder {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: #a0aab6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.self {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.system {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
display: grid;
|
||||||
|
flex: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #1477ff;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar.self {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-wrap {
|
||||||
|
display: flex;
|
||||||
|
max-width: 60%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-wrap.self {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender-name {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #8a94a6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f4f6f8;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.self .bubble {
|
||||||
|
background: #dff5eb;
|
||||||
|
color: #0f5132;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #a1a8b4;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-message {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #e6ebf2;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #eef1f5;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-hint {
|
||||||
|
color: #a0aab6;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
|
||||||
|
import { fetchChats, type ChatConversation } from '@/api/chats'
|
||||||
|
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
|
||||||
|
import { formatDateMinute } from '@/utils/time'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const loading = ref(false)
|
||||||
|
const conversations = ref<ChatConversation[]>([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const hasMore = computed(() => conversations.value.length < total.value)
|
||||||
|
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
|
||||||
|
|
||||||
|
const { onEvent } = useChatSSE('user', '/api/chats/events')
|
||||||
|
onEvent(handleSSEEvent)
|
||||||
|
|
||||||
|
onMounted(() => loadChats(true))
|
||||||
|
|
||||||
|
async function loadChats(isRefresh = false, showLoading = true) {
|
||||||
|
if (isRefresh) page.value = 1
|
||||||
|
if (loading.value) return
|
||||||
|
if (showLoading) loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetchChats(page.value, pageSize)
|
||||||
|
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
|
||||||
|
total.value = res.total
|
||||||
|
if (conversations.value.length < res.total && res.items.length > 0) {
|
||||||
|
page.value += 1
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('获取会话失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSSEEvent(event: ChatEvent) {
|
||||||
|
if (event.type === 'new_message' || event.type === 'conversation_updated') {
|
||||||
|
loadChats(true, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openConversation(item: ChatConversation) {
|
||||||
|
router.push(`/messages/${item.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string) {
|
||||||
|
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服' }
|
||||||
|
return map[role] || '成员'
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(item: ChatConversation) {
|
||||||
|
return item.last_message_preview || '订单群聊已创建'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page messages-page">
|
||||||
|
<div class="page-header-row">
|
||||||
|
<div class="page-header">
|
||||||
|
<p class="eyebrow">Messages</p>
|
||||||
|
<h1>消息</h1>
|
||||||
|
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊消息,与租客、号主和客服沟通。' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
|
||||||
|
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||||
|
|
||||||
|
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
||||||
|
<el-empty description="暂无订单群聊">
|
||||||
|
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
|
||||||
|
</el-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else v-loading="loading" class="conversation-list">
|
||||||
|
<button
|
||||||
|
v-for="item in conversations"
|
||||||
|
:key="item.id"
|
||||||
|
class="conversation-item"
|
||||||
|
type="button"
|
||||||
|
@click="openConversation(item)"
|
||||||
|
>
|
||||||
|
<div class="avatar-stack">
|
||||||
|
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
|
||||||
|
<span class="avatar support">客</span>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-body">
|
||||||
|
<div class="conversation-head">
|
||||||
|
<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 class="order-id">订单 #{{ item.order_id }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="conversation-preview">{{ previewText(item) }}</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="item.unread_count > 0" class="unread-badge">
|
||||||
|
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
|
||||||
|
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.messages-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-row {
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-loading,
|
||||||
|
.empty-panel {
|
||||||
|
min-height: 360px;
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-panel {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid #e8edf3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-item:hover {
|
||||||
|
border-color: #1477ff;
|
||||||
|
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-stack {
|
||||||
|
position: relative;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-head h2 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #17233d;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-time {
|
||||||
|
flex: none;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6b7785;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-chip {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef6ff;
|
||||||
|
color: #1477ff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-id {
|
||||||
|
color: #8a94a6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-preview {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unread-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
bottom: 14px;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ChatDotRound } from '@element-plus/icons-vue'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { fetchOrderChat } from '@/api/chats'
|
||||||
import { createDispute } from '@/api/disputes'
|
import { createDispute } from '@/api/disputes'
|
||||||
import { uploadFile } from '@/api/files'
|
import { uploadFile } from '@/api/files'
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +63,7 @@ const rejectReason = ref('')
|
|||||||
const disputeType = ref('cannot_login')
|
const disputeType = ref('cannot_login')
|
||||||
const disputeDescription = ref('')
|
const disputeDescription = ref('')
|
||||||
const disputeEvidenceText = ref('')
|
const disputeEvidenceText = ref('')
|
||||||
|
const openingChat = ref(false)
|
||||||
|
|
||||||
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
const isOwner = computed(() => order.value?.owner_id === session.userId)
|
||||||
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
const isRenter = computed(() => order.value?.renter_id === session.userId)
|
||||||
@@ -300,6 +303,19 @@ async function handleEvidenceUpload(event: Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openOrderChat() {
|
||||||
|
if (!order.value || openingChat.value) return
|
||||||
|
openingChat.value = true
|
||||||
|
try {
|
||||||
|
const chat = await fetchOrderChat(order.value.id)
|
||||||
|
await router.push(`/messages/${chat.id}`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('订单群聊暂不可用')
|
||||||
|
} finally {
|
||||||
|
openingChat.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function readError(error: unknown, fallback: string) {
|
function readError(error: unknown, fallback: string) {
|
||||||
if (typeof error === 'object' && error && 'response' in error) {
|
if (typeof error === 'object' && error && 'response' in error) {
|
||||||
const response = (error as { response?: { data?: { message?: string } } }).response
|
const response = (error as { response?: { data?: { message?: string } } }).response
|
||||||
@@ -463,7 +479,13 @@ function linesToList(value: string) {
|
|||||||
<div v-if="order" class="page-header">
|
<div v-if="order" class="page-header">
|
||||||
<p class="eyebrow">{{ order.order_no }}</p>
|
<p class="eyebrow">{{ order.order_no }}</p>
|
||||||
<h1>订单详情</h1>
|
<h1>订单详情</h1>
|
||||||
|
<div class="header-actions">
|
||||||
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
|
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
|
||||||
|
<el-button type="primary" :loading="openingChat" @click="openOrderChat">
|
||||||
|
<el-icon style="margin-right: 4px;"><ChatDotRound /></el-icon>
|
||||||
|
联系对方
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="order" class="detail-grid">
|
<div v-if="order" class="detail-grid">
|
||||||
@@ -644,6 +666,17 @@ function linesToList(value: string) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.checkout-resource-panel {
|
.checkout-resource-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
+30
-6
@@ -102,6 +102,10 @@ cleanup() {
|
|||||||
fi
|
fi
|
||||||
CLEANED_UP=1
|
CLEANED_UP=1
|
||||||
|
|
||||||
|
if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
log "正在停止前后端开发进程..."
|
log "正在停止前后端开发进程..."
|
||||||
if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
||||||
kill "${FRONTEND_PID}" >/dev/null 2>&1 || true
|
kill "${FRONTEND_PID}" >/dev/null 2>&1 || true
|
||||||
@@ -131,16 +135,24 @@ wait_container_healthy() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mysql_root() {
|
||||||
|
docker exec -i -e MYSQL_PWD=rootsecret hfb-mysql mysql -uroot "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
mysql_hfb() {
|
||||||
|
docker exec -i -e MYSQL_PWD=secret hfb-mysql mysql -uhfb "$@"
|
||||||
|
}
|
||||||
|
|
||||||
init_database() {
|
init_database() {
|
||||||
if [[ "${RESET_DB}" == "1" ]]; then
|
if [[ "${RESET_DB}" == "1" ]]; then
|
||||||
log_warn "重置数据库..."
|
log_warn "重置数据库..."
|
||||||
docker exec hfb-mysql mysql -uroot -prootsecret -e \
|
mysql_root -e \
|
||||||
"DROP DATABASE IF EXISTS hfb_sys; CREATE DATABASE hfb_sys CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
"DROP DATABASE IF EXISTS hfb_sys; CREATE DATABASE hfb_sys CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local table_count
|
local table_count
|
||||||
table_count="$(
|
table_count="$(
|
||||||
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
|
mysql_hfb -N -s -e \
|
||||||
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'users';" 2>/dev/null
|
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'users';" 2>/dev/null
|
||||||
)"
|
)"
|
||||||
|
|
||||||
@@ -149,12 +161,12 @@ init_database() {
|
|||||||
local migration
|
local migration
|
||||||
for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do
|
for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do
|
||||||
log "执行迁移:$(basename "${migration}")"
|
log "执行迁移:$(basename "${migration}")"
|
||||||
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${migration}"
|
mysql_hfb --default-character-set=utf8mb4 hfb_sys < "${migration}"
|
||||||
done
|
done
|
||||||
log_success "数据库初始化完成"
|
log_success "数据库初始化完成"
|
||||||
elif [[ "${SEED_ONLY}" == "1" ]]; then
|
elif [[ "${SEED_ONLY}" == "1" ]]; then
|
||||||
log "重新加载种子数据..."
|
log "重新加载种子数据..."
|
||||||
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_seed.sql"
|
mysql_hfb --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_seed.sql"
|
||||||
log_success "种子数据加载完成"
|
log_success "种子数据加载完成"
|
||||||
else
|
else
|
||||||
log "数据库已存在,跳过迁移(使用 --reset-db 重建)"
|
log "数据库已存在,跳过迁移(使用 --reset-db 重建)"
|
||||||
@@ -206,12 +218,12 @@ start_frontend() {
|
|||||||
|
|
||||||
watch_processes() {
|
watch_processes() {
|
||||||
while true; do
|
while true; do
|
||||||
if ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
|
if [[ -n "${BACKEND_PID}" ]] && ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
|
||||||
wait "${BACKEND_PID}" || true
|
wait "${BACKEND_PID}" || true
|
||||||
printf "后端进程已退出\n" >&2
|
printf "后端进程已退出\n" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
if [[ -n "${FRONTEND_PID}" ]] && ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
|
||||||
wait "${FRONTEND_PID}" || true
|
wait "${FRONTEND_PID}" || true
|
||||||
printf "前端进程已退出\n" >&2
|
printf "前端进程已退出\n" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -222,8 +234,12 @@ watch_processes() {
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
need_cmd docker
|
need_cmd docker
|
||||||
|
if [[ "${SEED_ONLY}" != "1" && "${NO_BACKEND}" == "0" ]]; then
|
||||||
need_cmd go
|
need_cmd go
|
||||||
|
fi
|
||||||
|
if [[ "${SEED_ONLY}" != "1" && "${NO_FRONTEND}" == "0" ]]; then
|
||||||
need_cmd npm
|
need_cmd npm
|
||||||
|
fi
|
||||||
|
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
trap 'cleanup; exit 130' INT TERM
|
trap 'cleanup; exit 130' INT TERM
|
||||||
@@ -252,8 +268,16 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "开发环境已启动"
|
log_success "开发环境已启动"
|
||||||
|
if [[ "${NO_FRONTEND}" == "0" ]]; then
|
||||||
log "前台:http://localhost:5173"
|
log "前台:http://localhost:5173"
|
||||||
log "后台:http://localhost:5173/admin/login(admin / admin123456)"
|
log "后台:http://localhost:5173/admin/login(admin / admin123456)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" ]]; then
|
||||||
|
log "仅启动 Docker 开发依赖,前后端未启动"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
|
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
|
||||||
|
|
||||||
watch_processes
|
watch_processes
|
||||||
|
|||||||
Reference in New Issue
Block a user