revert(order): 移除订单站内聊天,沟通统一走官方客服
- 删除 pages/order/chat、聊天接口/仓储方法/order_messages 建表与客户端 API - 买家「联系大神」、大神「联系老板」、大神详情「在线客服」、组局「私聊」统一改为 openSupportSheet(读取后台 customer_service 配置的客服微信/热线) - 保留派单实时通知与雷达/大厅轮询 - 114 套件/742 用例全绿;聊天接口线上已 404
这个提交包含在:
@@ -706,21 +706,6 @@ export async function getOrdersPool(options = {}) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 订单站内聊天(仅交易双方)
|
|
||||||
*/
|
|
||||||
export async function getOrderChat(orderId) {
|
|
||||||
return http.get(`/orders/${encodeURIComponent(orderId)}/chat`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendOrderMessage(orderId, content) {
|
|
||||||
return http.post(`/orders/${encodeURIComponent(orderId)}/chat/messages`, { content });
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function markOrderChatRead(orderId) {
|
|
||||||
return http.post(`/orders/${encodeURIComponent(orderId)}/chat/read`, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 坐标反查行政区/地址(雷达顶部定位文案)
|
* 坐标反查行政区/地址(雷达顶部定位文案)
|
||||||
*/
|
*/
|
||||||
|
|||||||
60
pwuniapp/common/support.js
普通文件
60
pwuniapp/common/support.js
普通文件
@@ -0,0 +1,60 @@
|
|||||||
|
// pwuniapp/common/support.js
|
||||||
|
// 统一客服入口:平台沟通仅通过官方客服微信/热线,不在买卖双方之间直接建立联系。
|
||||||
|
import { getPlatformConfigs } from '@/common/api.js';
|
||||||
|
|
||||||
|
let cachedContact = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
export async function getSupportContact() {
|
||||||
|
if (cachedContact && Date.now() - cachedAt < 5 * 60 * 1000) {
|
||||||
|
return cachedContact;
|
||||||
|
}
|
||||||
|
let hotline = '';
|
||||||
|
let wechat = '';
|
||||||
|
try {
|
||||||
|
const configs = await getPlatformConfigs();
|
||||||
|
const cs = (configs && configs.customer_service) || {};
|
||||||
|
hotline = String(cs.hotline || '').trim();
|
||||||
|
wechat = String(cs.wechat || '').trim();
|
||||||
|
} catch (_) {
|
||||||
|
// 配置拉取失败时使用空值,避免展示写死的客服账号
|
||||||
|
}
|
||||||
|
cachedContact = { hotline, wechat };
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return cachedContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSupportSheet() {
|
||||||
|
const contact = await getSupportContact();
|
||||||
|
const actions = [];
|
||||||
|
if (contact.wechat) actions.push({ key: 'wechat', label: `复制客服微信(${contact.wechat})` });
|
||||||
|
if (contact.hotline) actions.push({ key: 'hotline', label: `拨打客服热线(${contact.hotline})` });
|
||||||
|
|
||||||
|
if (actions.length === 0) {
|
||||||
|
uni.showToast({ title: '客服信息暂未配置,请稍后再试', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: actions.map((a) => a.label),
|
||||||
|
success: (res) => {
|
||||||
|
const picked = actions[res.tapIndex];
|
||||||
|
if (!picked) return;
|
||||||
|
if (picked.key === 'wechat') {
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: contact.wechat,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '客服微信已复制,请前往微信添加', icon: 'none', duration: 2200 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (picked.key === 'hotline') {
|
||||||
|
uni.makePhoneCall({
|
||||||
|
phoneNumber: contact.hotline.replace(/[^0-9+]/g, ''),
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: `客服热线:${contact.hotline}`, icon: 'none' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -102,15 +102,7 @@
|
|||||||
"backgroundColor": "#0A0F1D"
|
"backgroundColor": "#0A0F1D"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/order/chat",
|
|
||||||
"style": {
|
|
||||||
"navigationBarTitleText": "订单沟通",
|
|
||||||
"navigationBarBackgroundColor": "#0A0F1D",
|
|
||||||
"navigationBarTextStyle": "white",
|
|
||||||
"backgroundColor": "#0A0F1D"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/user/index",
|
"path": "pages/user/index",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="creator-sub">同城组局召集人</text>
|
<text class="creator-sub">同城组局召集人</text>
|
||||||
</view>
|
</view>
|
||||||
<button class="btn-chat btn-active" @click.stop="chatWithHost">私聊</button>
|
<button class="btn-chat btn-active" @click.stop="openSupportSheet">联系客服</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="info-list">
|
<view class="info-list">
|
||||||
@@ -422,6 +422,7 @@ import {
|
|||||||
normalizeUserId
|
normalizeUserId
|
||||||
} from '@/common/api.js';
|
} from '@/common/api.js';
|
||||||
import { ICONS } from '@/common/icons.js';
|
import { ICONS } from '@/common/icons.js';
|
||||||
|
import { openSupportSheet } from '@/common/support.js';
|
||||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||||
import { ensureLoginOrRedirect } from '@/common/role.js';
|
import { ensureLoginOrRedirect } from '@/common/role.js';
|
||||||
import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js';
|
import { preloadSubscribeTemplates, requestSubscribeFor } from '@/common/subscribe.js';
|
||||||
@@ -711,34 +712,6 @@ const openHostProfile = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const chatWithHost = () => {
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: [
|
|
||||||
'复制组局信息',
|
|
||||||
'查看发起人主页'
|
|
||||||
],
|
|
||||||
success: (res) => {
|
|
||||||
if (res.tapIndex === 0) {
|
|
||||||
const parts = [
|
|
||||||
`【组局】${activity.value.title || ''}`,
|
|
||||||
`时间:${activity.value.startTime || '待定'}`,
|
|
||||||
`地点:${activity.value.location || '待定'}`
|
|
||||||
];
|
|
||||||
const contact = String(activity.value.contactPhone || '').trim();
|
|
||||||
if (contact) parts.push(`联络方式:${contact}`);
|
|
||||||
uni.setClipboardData({
|
|
||||||
data: parts.join(' | '),
|
|
||||||
success: () => {
|
|
||||||
uni.showToast({ title: '组局信息已复制', icon: 'success' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (res.tapIndex === 1) {
|
|
||||||
openHostProfile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToMyTicket = (code) => {
|
const goToMyTicket = (code) => {
|
||||||
if (!ensureLoginOrRedirect(`/pages/activity/detail?id=${currentActId.value}`)) {
|
if (!ensureLoginOrRedirect(`/pages/activity/detail?id=${currentActId.value}`)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ import { onLoad, onShow } from '@dcloudio/uni-app';
|
|||||||
import SkeletonScreen from '@/components/SkeletonScreen.vue';
|
import SkeletonScreen from '@/components/SkeletonScreen.vue';
|
||||||
import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js';
|
import { getCompanionDetail, getExpertReviews, replyOrderReview, submitReport as submitReportApi, checkFavorite, addFavorite as addFavApi, removeFavorite as removeFavApi } from '@/common/api.js';
|
||||||
import { ICONS } from '@/common/icons.js';
|
import { ICONS } from '@/common/icons.js';
|
||||||
|
import { openSupportSheet } from '@/common/support.js';
|
||||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||||
import { ensureLoginOrRedirect } from '@/common/role.js';
|
import { ensureLoginOrRedirect } from '@/common/role.js';
|
||||||
|
|
||||||
@@ -532,36 +533,7 @@ const toggleVoice = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chatOnline = () => {
|
const chatOnline = () => {
|
||||||
uni.showActionSheet({
|
void openSupportSheet();
|
||||||
itemList: [`联系 7x24h 官方在线客服`],
|
|
||||||
success: (res) => {
|
|
||||||
if (res.tapIndex === 0) {
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: [
|
|
||||||
'拨打官方服务热线 (400-888-6666)',
|
|
||||||
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
|
|
||||||
],
|
|
||||||
success: (cRes) => {
|
|
||||||
if (cRes.tapIndex === 0) {
|
|
||||||
uni.makePhoneCall({
|
|
||||||
phoneNumber: '4008886666',
|
|
||||||
fail: () => {
|
|
||||||
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (cRes.tapIndex === 1) {
|
|
||||||
uni.setClipboardData({
|
|
||||||
data: 'peiwan_vip_kefu',
|
|
||||||
success: () => {
|
|
||||||
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const previewImage = (index) => {
|
const previewImage = (index) => {
|
||||||
|
|||||||
@@ -466,6 +466,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { onShow } from '@dcloudio/uni-app';
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
import { ICONS } from '@/common/icons.js';
|
import { ICONS } from '@/common/icons.js';
|
||||||
|
import { openSupportSheet } from '@/common/support.js';
|
||||||
import { isExpertCertified } from '@/common/role.js';
|
import { isExpertCertified } from '@/common/role.js';
|
||||||
import { getCurrentThemeKey } from '@/common/theme.js';
|
import { getCurrentThemeKey } from '@/common/theme.js';
|
||||||
import {
|
import {
|
||||||
@@ -1024,42 +1025,8 @@ const handleRejectOrder = (order) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const chatWithBuyer = (order) => {
|
const chatWithBuyer = () => {
|
||||||
const orderId = order && order.id;
|
void openSupportSheet();
|
||||||
if (orderId) {
|
|
||||||
uni.navigateTo({ url: `/pages/order/chat?orderId=${encodeURIComponent(orderId)}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: ['联系 7x24h 官方客服'],
|
|
||||||
success: (res) => {
|
|
||||||
if (res.tapIndex === 0) {
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: [
|
|
||||||
'拨打官方服务热线 (400-888-6666)',
|
|
||||||
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
|
|
||||||
],
|
|
||||||
success: (cRes) => {
|
|
||||||
if (cRes.tapIndex === 0) {
|
|
||||||
uni.makePhoneCall({
|
|
||||||
phoneNumber: '4008886666',
|
|
||||||
fail: () => {
|
|
||||||
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (cRes.tapIndex === 1) {
|
|
||||||
uni.setClipboardData({
|
|
||||||
data: 'peiwan_vip_kefu',
|
|
||||||
success: () => {
|
|
||||||
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const completeOrder = (id) => {
|
const completeOrder = (id) => {
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -359,6 +359,7 @@ import {
|
|||||||
normalizeUserId
|
normalizeUserId
|
||||||
} from '@/common/api.js';
|
} from '@/common/api.js';
|
||||||
import { ICONS } from '@/common/icons.js';
|
import { ICONS } from '@/common/icons.js';
|
||||||
|
import { openSupportSheet } from '@/common/support.js';
|
||||||
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
import { getCurrentThemeKey, applyThemeToNativeUI } from '@/common/theme.js';
|
||||||
import { ensureLoginOrRedirect } from '@/common/role.js';
|
import { ensureLoginOrRedirect } from '@/common/role.js';
|
||||||
import { getNavBarInfo } from '@/common/nav.js';
|
import { getNavBarInfo } from '@/common/nav.js';
|
||||||
@@ -546,43 +547,8 @@ const payOrder = async (id) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactExpert = (order) => {
|
const contactExpert = () => {
|
||||||
const orderId = order && order.id;
|
void openSupportSheet();
|
||||||
const chatAvailable = Boolean(orderId) && ['IN_SERVICE', '服务中', '履约中', '待确认', '已完成'].includes(String(order.rawStatus || order.status || ''));
|
|
||||||
if (chatAvailable) {
|
|
||||||
uni.navigateTo({ url: `/pages/order/chat?orderId=${encodeURIComponent(orderId)}` });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: ['联系 7x24h 官方电竞客服'],
|
|
||||||
success: (res) => {
|
|
||||||
if (res.tapIndex === 0) {
|
|
||||||
uni.showActionSheet({
|
|
||||||
itemList: [
|
|
||||||
'拨打官方服务热线 (400-888-6666)',
|
|
||||||
'复制官方 VIP 客服微信 (peiwan_vip_kefu)'
|
|
||||||
],
|
|
||||||
success: (cRes) => {
|
|
||||||
if (cRes.tapIndex === 0) {
|
|
||||||
uni.makePhoneCall({
|
|
||||||
phoneNumber: '4008886666',
|
|
||||||
fail: () => {
|
|
||||||
uni.showToast({ title: '服务热线: 400-888-6666', icon: 'none' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (cRes.tapIndex === 1) {
|
|
||||||
uni.setClipboardData({
|
|
||||||
data: 'peiwan_vip_kefu',
|
|
||||||
success: () => {
|
|
||||||
uni.showToast({ title: '官方客服微信号已复制', icon: 'success' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 15分钟待支付倒计时动态计算
|
// 15分钟待支付倒计时动态计算
|
||||||
|
|||||||
@@ -159,21 +159,6 @@ export async function initDatabase(): Promise<void> {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// 订单维度站内聊天消息(仅交易双方可见,敏感词/联系方式在服务端拦截)
|
|
||||||
await conn.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS order_messages (
|
|
||||||
id VARCHAR(64) PRIMARY KEY,
|
|
||||||
order_id VARCHAR(64) NOT NULL,
|
|
||||||
sender_id INT NOT NULL,
|
|
||||||
receiver_id INT NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
read_at DATETIME NULL,
|
|
||||||
INDEX idx_order_created (order_id, created_at),
|
|
||||||
INDEX idx_receiver_read (receiver_id, read_at)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
`);
|
|
||||||
|
|
||||||
const extraOrderCols = [
|
const extraOrderCols = [
|
||||||
"ALTER TABLE orders ADD COLUMN buyer_id INT DEFAULT 1001 AFTER id",
|
"ALTER TABLE orders ADD COLUMN buyer_id INT DEFAULT 1001 AFTER id",
|
||||||
"ALTER TABLE orders ADD COLUMN expert_id INT DEFAULT 101 AFTER buyer_id",
|
"ALTER TABLE orders ADD COLUMN expert_id INT DEFAULT 101 AFTER buyer_id",
|
||||||
|
|||||||
@@ -1686,66 +1686,6 @@ export class MysqlRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 订单聊天上下文:仅买家与已认领大神可参与 */
|
|
||||||
async getOrderChatContext(orderId: string): Promise<any | null> {
|
|
||||||
const own = await this.getOrderOwnership(orderId);
|
|
||||||
if (!own) return null;
|
|
||||||
return {
|
|
||||||
orderId: own.id,
|
|
||||||
orderNo: own.orderNo,
|
|
||||||
buyerId: own.buyerId,
|
|
||||||
expertUserId: Number(own.claimedBy) || 0,
|
|
||||||
status: own.status,
|
|
||||||
amount: own.amount,
|
|
||||||
companionName: own.companionName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async listOrderMessages(orderId: string, limit = 100): Promise<any[]> {
|
|
||||||
const safeLimit = Math.min(200, Math.max(1, Math.round(Number(limit) || 100)));
|
|
||||||
const [rows]: any = await pool.query(`
|
|
||||||
SELECT id, order_id as orderId, sender_id as senderId, receiver_id as receiverId,
|
|
||||||
content, created_at as createdAt, read_at as readAt
|
|
||||||
FROM order_messages
|
|
||||||
WHERE order_id = ?
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
LIMIT ?
|
|
||||||
`, [orderId, safeLimit]);
|
|
||||||
return rows || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrderMessage(orderId: string, senderId: number, receiverId: number, content: string): Promise<any> {
|
|
||||||
const id = `msg_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
|
||||||
await pool.query(
|
|
||||||
'INSERT INTO order_messages (id, order_id, sender_id, receiver_id, content) VALUES (?, ?, ?, ?, ?)',
|
|
||||||
[id, orderId, senderId, receiverId, content]
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
orderId,
|
|
||||||
senderId: Number(senderId),
|
|
||||||
receiverId: Number(receiverId),
|
|
||||||
content,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
readAt: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async markOrderMessagesRead(orderId: string, readerId: number): Promise<void> {
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE order_messages SET read_at = NOW() WHERE order_id = ? AND receiver_id = ? AND read_at IS NULL',
|
|
||||||
[orderId, readerId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async countUnreadOrderMessages(orderId: string, receiverId: number): Promise<number> {
|
|
||||||
const [rows]: any = await pool.query(
|
|
||||||
'SELECT COUNT(*) as c FROM order_messages WHERE order_id = ? AND receiver_id = ? AND read_at IS NULL',
|
|
||||||
[orderId, receiverId]
|
|
||||||
);
|
|
||||||
return Number(rows?.[0]?.c || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
||||||
async applyCompleteOrder(orderId: string): Promise<any> {
|
async applyCompleteOrder(orderId: string): Promise<any> {
|
||||||
const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]);
|
const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]);
|
||||||
|
|||||||
@@ -1548,106 +1548,6 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 订单维度站内聊天(仅交易双方;敏感词与手机号在服务端拦截)=====
|
|
||||||
async function resolveOrderChat(req: Request, orderId: string) {
|
|
||||||
const uid = resolveSecureUserId(req, Number(req.body?.userId || req.query.userId || 0), '订单聊天');
|
|
||||||
const ctx = await repo.getOrderChatContext(orderId);
|
|
||||||
if (!ctx) {
|
|
||||||
const e: any = new Error('订单不存在');
|
|
||||||
e.status = 404;
|
|
||||||
e.code = 40400;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
if (!ctx.expertUserId) {
|
|
||||||
const e: any = new Error('订单尚未被大神接单,暂不能聊天');
|
|
||||||
e.status = 409;
|
|
||||||
e.code = 40900;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
const isBuyer = uid === ctx.buyerId;
|
|
||||||
const isExpert = uid === ctx.expertUserId;
|
|
||||||
if (!isBuyer && !isExpert) {
|
|
||||||
const e: any = new Error('仅交易双方可查看该订单会话');
|
|
||||||
e.status = 403;
|
|
||||||
e.code = 40300;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
return { uid, ctx, isBuyer, peerId: isBuyer ? ctx.expertUserId : ctx.buyerId };
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/api/orders/:id/chat', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { uid, ctx, peerId } = await resolveOrderChat(req, String(req.params.id));
|
|
||||||
const messages = await repo.listOrderMessages(ctx.orderId);
|
|
||||||
const peer: any = await repo.getUserById(peerId);
|
|
||||||
await repo.markOrderMessagesRead(ctx.orderId, uid);
|
|
||||||
res.json({
|
|
||||||
code: 20000,
|
|
||||||
data: {
|
|
||||||
order: {
|
|
||||||
id: ctx.orderId,
|
|
||||||
orderNo: ctx.orderNo,
|
|
||||||
status: ctx.status,
|
|
||||||
amount: ctx.amount,
|
|
||||||
title: ctx.companionName
|
|
||||||
},
|
|
||||||
peer: {
|
|
||||||
id: peerId,
|
|
||||||
nickname: peer?.nickname || '对方',
|
|
||||||
avatar: peer?.avatar || ''
|
|
||||||
},
|
|
||||||
messages,
|
|
||||||
unread: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(err.status || 500).json({ code: err.code || 50000, message: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/orders/:id/chat/messages', rateLimit({ name: 'order_chat', windowMs: 60 * 1000, max: 40, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { uid, ctx, peerId } = await resolveOrderChat(req, String(req.params.id));
|
|
||||||
const content = String(req.body?.content || '').trim();
|
|
||||||
if (!content) {
|
|
||||||
return res.status(400).json({ code: 40000, message: '消息内容不能为空' });
|
|
||||||
}
|
|
||||||
if (content.length > 500) {
|
|
||||||
return res.status(400).json({ code: 40000, message: '单条消息不能超过 500 字' });
|
|
||||||
}
|
|
||||||
if (/1[3-9]\d{9}/.test(content)) {
|
|
||||||
return res.status(400).json({ code: 40000, message: '为保障资金与人身安全,请勿在站内发送手机号' });
|
|
||||||
}
|
|
||||||
const sec = contentSecurityService.checkText(content);
|
|
||||||
if (!sec.pass) {
|
|
||||||
return res.status(400).json({ code: sec.code || 40000, message: sec.message || '消息包含违规内容' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = await repo.createOrderMessage(ctx.orderId, uid, peerId, content);
|
|
||||||
const sender: any = await repo.getUserById(uid);
|
|
||||||
pushUserNotification(
|
|
||||||
peerId,
|
|
||||||
'CHAT',
|
|
||||||
'收到一条订单消息',
|
|
||||||
`${sender?.nickname || '对方'}:${content.slice(0, 40)}`,
|
|
||||||
`/pages/order/chat?orderId=${ctx.orderId}`
|
|
||||||
);
|
|
||||||
res.json({ code: 20000, data: message, message: '已发送' });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(err.status || 400).json({ code: err.code || 40000, message: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/orders/:id/chat/read', async (req: Request, res: Response) => {
|
|
||||||
try {
|
|
||||||
const { uid, ctx } = await resolveOrderChat(req, String(req.params.id));
|
|
||||||
await repo.markOrderMessagesRead(ctx.orderId, uid);
|
|
||||||
res.json({ code: 20000, data: { ok: true } });
|
|
||||||
} catch (err: any) {
|
|
||||||
res.status(err.status || 400).json({ code: err.code || 40000, message: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 大神今日接单数据汇总 (落盘 MySQL)
|
// 大神今日接单数据汇总 (落盘 MySQL)
|
||||||
app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
|
app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,47 +3,19 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 第九十九批:订单站内聊天 + 派单实时通知。
|
* 第九十九批:派单实时化;站内聊天已按产品要求移除(沟通统一走官方客服)。
|
||||||
*/
|
*/
|
||||||
describe('第九十九批订单聊天与派单实时化', () => {
|
describe('第九十九批派单实时化与客服沟通约束', () => {
|
||||||
const root = path.resolve(__dirname, '../../');
|
const root = path.resolve(__dirname, '../../');
|
||||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf-8');
|
||||||
|
|
||||||
const mysql = read('server/src/db/mysql.ts');
|
|
||||||
const repo = read('server/src/db/repository.ts');
|
|
||||||
const indexCode = read('server/src/index.ts');
|
const indexCode = read('server/src/index.ts');
|
||||||
const apiCode = read('pwuniapp/common/api.js');
|
const mysql = read('server/src/db/mysql.ts');
|
||||||
const chatPage = read('pwuniapp/pages/order/chat.vue');
|
|
||||||
const pagesJson = read('pwuniapp/pages.json');
|
const pagesJson = read('pwuniapp/pages.json');
|
||||||
const radar = read('pwuniapp/pages/expert/radar.vue');
|
const radar = read('pwuniapp/pages/expert/radar.vue');
|
||||||
const hall = read('pwuniapp/pages/expert/hall.vue');
|
const hall = read('pwuniapp/pages/expert/hall.vue');
|
||||||
|
const orderList = read('pwuniapp/pages/order/list.vue');
|
||||||
it('订单消息表必须落库且带索引', () => {
|
const workspace = read('pwuniapp/pages/expert/workspace.vue');
|
||||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS order_messages');
|
|
||||||
expect(mysql).toContain('idx_order_created');
|
|
||||||
expect(mysql).toContain('idx_receiver_read');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('聊天接口必须校验交易双方身份', () => {
|
|
||||||
expect(indexCode).toContain("app.get('/api/orders/:id/chat'");
|
|
||||||
expect(indexCode).toContain("app.post('/api/orders/:id/chat/messages'");
|
|
||||||
expect(indexCode).toContain('仅交易双方可查看该订单会话');
|
|
||||||
expect(indexCode).toContain('订单尚未被大神接单,暂不能聊天');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('消息必须过滤手机号与违规内容,并推送站内通知', () => {
|
|
||||||
expect(indexCode).toContain("请勿在站内发送手机号");
|
|
||||||
expect(indexCode).toContain('contentSecurityService.checkText(content)');
|
|
||||||
expect(indexCode).toContain("pushUserNotification(\n peerId,\n 'CHAT'");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('小程序聊天页与接口已接线', () => {
|
|
||||||
expect(pagesJson).toContain('pages/order/chat');
|
|
||||||
expect(apiCode).toContain('export async function getOrderChat');
|
|
||||||
expect(apiCode).toContain('export async function sendOrderMessage');
|
|
||||||
expect(chatPage).toContain('setInterval');
|
|
||||||
expect(chatPage).toContain('sendOrderMessage');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('新派单必须通知在线认证大神,雷达/大厅自动轮询', () => {
|
it('新派单必须通知在线认证大神,雷达/大厅自动轮询', () => {
|
||||||
expect(indexCode).toContain('notifyNearbyExpertsForOrder');
|
expect(indexCode).toContain('notifyNearbyExpertsForOrder');
|
||||||
@@ -55,9 +27,27 @@ describe('第九十九批订单聊天与派单实时化', () => {
|
|||||||
expect(hall).toContain('}, 30000);');
|
expect(hall).toContain('}, 30000);');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('通知跳转会正确处理接单页 tab 路由', () => {
|
it('新单通知跳转会正确处理接单页 tab 路由', () => {
|
||||||
const notifications = read('pwuniapp/pages/user/notifications.vue');
|
const notifications = read('pwuniapp/pages/user/notifications.vue');
|
||||||
expect(notifications).toContain("'/pages/expert/radar'");
|
expect(notifications).toContain("'/pages/expert/radar'");
|
||||||
expect(notifications).toContain("'/pages/expert/hall'");
|
expect(notifications).toContain("'/pages/expert/hall'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('站内聊天必须彻底移除(页面/接口/表/客户端方法)', () => {
|
||||||
|
expect(fs.existsSync(path.join(root, 'pwuniapp/pages/order/chat.vue'))).toBe(false);
|
||||||
|
expect(pagesJson).not.toContain('pages/order/chat');
|
||||||
|
expect(indexCode).not.toContain('/chat/messages');
|
||||||
|
expect(mysql).not.toContain('order_messages');
|
||||||
|
const apiCode = read('pwuniapp/common/api.js');
|
||||||
|
expect(apiCode).not.toContain('sendOrderMessage');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('买卖双方沟通统一走官方客服(配置化)', () => {
|
||||||
|
const support = read('pwuniapp/common/support.js');
|
||||||
|
expect(support).toContain('openSupportSheet');
|
||||||
|
expect(support).toContain('customer_service');
|
||||||
|
expect(orderList).toContain('openSupportSheet');
|
||||||
|
expect(workspace).toContain('openSupportSheet');
|
||||||
|
expect(orderList).not.toContain('pages/order/chat');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户