- 新派单(余额支付立即入池 / 微信支付确认后入池)向在线认证大神推送 ORDER_POOL 站内通知;雷达 20s、接单大厅 30s 自动轮询 - 新增 order_messages 表与订单会话接口:仅买家与已认领大神可访问,服务端拦截手机号与违规内容,消息落库并推送 CHAT 通知 - 新增 pages/order/chat 聊天页(3s 轮询、已读、发送),买家订单「联系大神」与大神工作台「联系老板」接入 - 114 套件/744 用例全绿;线上实测聊天/越权/手机号拦截/新单通知均通过,测试数据已清理
321 行
6.9 KiB
Vue
321 行
6.9 KiB
Vue
<template>
|
|
<view class="chat-page">
|
|
<view class="chat-header">
|
|
<image class="peer-avatar" :src="peer.avatar || ''" mode="aspectFill" />
|
|
<view class="peer-meta">
|
|
<text class="peer-name">{{ peer.nickname || '对方' }}</text>
|
|
<text class="order-sub">{{ order.title || '订单沟通' }}{{ order.orderNo ? ` · ${order.orderNo}` : '' }}</text>
|
|
</view>
|
|
</view>
|
|
|
|
<scroll-view class="chat-body" scroll-y :scroll-into-view="scrollTarget">
|
|
<view v-if="loading" class="chat-hint">正在加载会话…</view>
|
|
<view v-else-if="messages.length === 0" class="chat-hint">暂无消息,先打个招呼吧</view>
|
|
|
|
<view
|
|
v-for="m in messages"
|
|
:key="m.id"
|
|
:id="`msg-${m.id}`"
|
|
class="msg-row"
|
|
:class="{ mine: isMine(m) }"
|
|
>
|
|
<image v-if="!isMine(m)" class="msg-avatar" :src="peer.avatar || ''" mode="aspectFill" />
|
|
<view class="bubble-wrap">
|
|
<view class="msg-bubble">{{ m.content }}</view>
|
|
<text class="msg-time">{{ formatTime(m.createdAt) }}</text>
|
|
</view>
|
|
</view>
|
|
<view id="chat-bottom" class="chat-bottom-anchor"></view>
|
|
</scroll-view>
|
|
|
|
<view class="chat-input-bar">
|
|
<input
|
|
class="chat-input"
|
|
v-model="draft"
|
|
confirm-type="send"
|
|
:maxlength="500"
|
|
:disabled="!chatEnabled"
|
|
placeholder="输入消息(请勿发送手机号/微信号)"
|
|
placeholder-class="chat-ph"
|
|
@confirm="send"
|
|
/>
|
|
<button class="chat-send btn-active" :disabled="sending || !draft.trim() || !chatEnabled" @click="send">
|
|
{{ sending ? '发送中' : '发送' }}
|
|
</button>
|
|
</view>
|
|
</view>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onUnmounted } from 'vue';
|
|
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
|
|
import { getOrderChat, sendOrderMessage, normalizeUserId } from '@/common/api.js';
|
|
|
|
const orderId = ref('');
|
|
const order = ref({});
|
|
const peer = ref({});
|
|
const messages = ref([]);
|
|
const draft = ref('');
|
|
const loading = ref(true);
|
|
const sending = ref(false);
|
|
const chatEnabled = ref(true);
|
|
const scrollTarget = ref('chat-bottom');
|
|
|
|
let pollTimer = null;
|
|
let polling = false;
|
|
|
|
const isMine = (m) => Number(m.senderId) === Number(normalizeUserId());
|
|
|
|
const formatTime = (val) => {
|
|
if (!val) return '';
|
|
const d = new Date(val);
|
|
if (isNaN(d.getTime())) return '';
|
|
const hh = String(d.getHours()).padStart(2, '0');
|
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
return `${hh}:${mm}`;
|
|
};
|
|
|
|
const scrollToBottom = () => {
|
|
scrollTarget.value = '';
|
|
setTimeout(() => {
|
|
scrollTarget.value = 'chat-bottom';
|
|
}, 30);
|
|
};
|
|
|
|
const loadChat = async (first = false) => {
|
|
if (polling || !orderId.value) return;
|
|
polling = true;
|
|
try {
|
|
const data = await getOrderChat(orderId.value);
|
|
if (data) {
|
|
order.value = data.order || {};
|
|
peer.value = data.peer || {};
|
|
const list = Array.isArray(data.messages) ? data.messages : [];
|
|
if (list.length !== messages.value.length || first) {
|
|
messages.value = list;
|
|
scrollToBottom();
|
|
}
|
|
chatEnabled.value = true;
|
|
}
|
|
} catch (err) {
|
|
if (first) {
|
|
chatEnabled.value = false;
|
|
uni.showToast({ title: err.message || '会话暂不可用', icon: 'none' });
|
|
}
|
|
} finally {
|
|
polling = false;
|
|
if (first) loading.value = false;
|
|
}
|
|
};
|
|
|
|
const startPolling = () => {
|
|
stopPolling();
|
|
pollTimer = setInterval(() => {
|
|
void loadChat(false);
|
|
}, 3000);
|
|
};
|
|
|
|
const stopPolling = () => {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
};
|
|
|
|
const send = async () => {
|
|
const content = String(draft.value || '').trim();
|
|
if (!content || sending.value || !chatEnabled.value) return;
|
|
sending.value = true;
|
|
try {
|
|
const msg = await sendOrderMessage(orderId.value, content);
|
|
if (msg && msg.id) {
|
|
messages.value = [...messages.value, msg];
|
|
draft.value = '';
|
|
scrollToBottom();
|
|
}
|
|
} catch (err) {
|
|
uni.showToast({ title: err.message || '发送失败', icon: 'none' });
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
};
|
|
|
|
onLoad((query) => {
|
|
orderId.value = query && query.orderId ? String(query.orderId) : '';
|
|
if (!orderId.value) {
|
|
uni.showToast({ title: '缺少订单信息', icon: 'none' });
|
|
return;
|
|
}
|
|
void loadChat(true);
|
|
});
|
|
|
|
onShow(() => {
|
|
if (orderId.value) {
|
|
void loadChat(false);
|
|
startPolling();
|
|
}
|
|
});
|
|
|
|
onHide(() => {
|
|
stopPolling();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
stopPolling();
|
|
});
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
page {
|
|
height: 100%;
|
|
}
|
|
|
|
.chat-page {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #0A0F1D;
|
|
color: #F8FAFC;
|
|
}
|
|
|
|
.chat-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16rpx;
|
|
padding: 20rpx 28rpx;
|
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.08);
|
|
background: #111827;
|
|
|
|
.peer-avatar {
|
|
width: 72rpx;
|
|
height: 72rpx;
|
|
border-radius: 50%;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.peer-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4rpx;
|
|
}
|
|
|
|
.peer-name {
|
|
font-size: 28rpx;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.order-sub {
|
|
font-size: 22rpx;
|
|
color: #94A3B8;
|
|
}
|
|
}
|
|
|
|
.chat-body {
|
|
flex: 1;
|
|
padding: 24rpx 24rpx 12rpx;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.chat-hint {
|
|
text-align: center;
|
|
font-size: 24rpx;
|
|
color: #64748B;
|
|
padding: 60rpx 0;
|
|
}
|
|
|
|
.chat-bottom-anchor {
|
|
height: 1rpx;
|
|
}
|
|
|
|
.msg-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12rpx;
|
|
margin-bottom: 20rpx;
|
|
|
|
.msg-avatar {
|
|
width: 64rpx;
|
|
height: 64rpx;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.bubble-wrap {
|
|
max-width: 70%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6rpx;
|
|
}
|
|
|
|
.msg-bubble {
|
|
padding: 16rpx 22rpx;
|
|
border-radius: 20rpx;
|
|
border-top-left-radius: 6rpx;
|
|
background: #1E293B;
|
|
color: #E2E8F0;
|
|
font-size: 28rpx;
|
|
line-height: 1.5;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.msg-time {
|
|
font-size: 20rpx;
|
|
color: #64748B;
|
|
}
|
|
|
|
&.mine {
|
|
justify-content: flex-end;
|
|
|
|
.bubble-wrap {
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.msg-bubble {
|
|
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
|
|
color: #FFFFFF;
|
|
border-radius: 20rpx;
|
|
border-top-right-radius: 6rpx;
|
|
}
|
|
}
|
|
}
|
|
|
|
.chat-input-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16rpx;
|
|
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
|
border-top: 1rpx solid rgba(255, 255, 255, 0.08);
|
|
background: #111827;
|
|
|
|
.chat-input {
|
|
flex: 1;
|
|
height: 76rpx;
|
|
padding: 0 24rpx;
|
|
border-radius: 38rpx;
|
|
background: #1E293B;
|
|
color: #F8FAFC;
|
|
font-size: 28rpx;
|
|
}
|
|
|
|
.chat-ph {
|
|
color: #64748B;
|
|
}
|
|
|
|
.chat-send {
|
|
margin: 0;
|
|
padding: 0 30rpx;
|
|
height: 72rpx;
|
|
line-height: 72rpx;
|
|
border-radius: 36rpx;
|
|
font-size: 26rpx;
|
|
color: #FFFFFF;
|
|
background: linear-gradient(135deg, #10B981 0%, #059669 100%);
|
|
|
|
&[disabled] {
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
}
|
|
</style>
|