4 Commits

23 changed files with 520 additions and 94 deletions

View File

@@ -8,8 +8,7 @@
import { onMounted } from "vue"; import { onMounted } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import storage from "@/plugins/storage"; import storage from "@/plugins/storage";
import { getBaseSite, getThemeSetting } from "@/api/common.js"; import { loadSiteSettings } from "@/composables/useSiteSettings";
import { fetchAndApplyTheme } from "@/utils/theme";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
defineOptions({ name: "App" }); defineOptions({ name: "App" });
@@ -49,27 +48,15 @@ function init() {
}); });
} }
getSite(); getSite();
getTheme();
} }
function getSite() { function getSite() {
getBaseSite().then((res) => { loadSiteSettings().then((data) => {
if (res.success && res.result.settingValue) { if (data) {
try {
const data = JSON.parse(res.result.settingValue);
applySiteInfo(data); applySiteInfo(data);
} catch {
// 站点配置解析失败时使用缓存
}
} }
}).catch((err) => { }).catch((err) => {
console.warn('获取站点配置失败', err); console.warn("获取站点配置失败", err);
});
}
function getTheme() {
fetchAndApplyTheme(getThemeSetting).catch((err) => {
console.warn('获取主题配置失败', err);
}); });
} }

View File

@@ -37,6 +37,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import storage from '@/plugins/storage';
import { logout } from '@/api/account';
const props = defineProps({ const props = defineProps({
options: { options: {
@@ -69,12 +71,22 @@ const username = computed(() => {
return ''; return '';
}); });
function handleLogout() { function clearLocalAuth() {
storage.removeItem('userInfo');
storage.removeItem('accessToken');
storage.removeItem('refreshToken');
storage.removeItem('cartNum');
Cookies.remove('userInfo'); Cookies.remove('userInfo');
Cookies.remove('accessToken'); Cookies.remove('accessToken');
window.location.reload(); window.location.reload();
} }
function handleLogout() {
logout().finally(() => {
clearLocalAuth();
});
}
const customStyle = computed(() => { const customStyle = computed(() => {
const s = props.componentStyle || {}; const s = props.componentStyle || {};
const opt = props.options || {}; const opt = props.options || {};

View File

@@ -2,11 +2,14 @@ import { computed, onMounted, ref } from "vue";
import { getBaseSite } from "@/api/common.js"; import { getBaseSite } from "@/api/common.js";
import config from "@/config"; import config from "@/config";
// 同页的页头、页脚共享请求;每次重新进入页面时刷新,避免旧缓存覆盖后台设置。 // 全局共享站点配置;应用生命周期内仅拉取一次,同页与切页均复用请求与结果。
const settings = ref(null); const settings = ref(null);
let pendingRequest; let pendingRequest = null;
export function loadSiteSettings() { export function loadSiteSettings() {
if (settings.value) {
return Promise.resolve(settings.value);
}
if (!pendingRequest) { if (!pendingRequest) {
pendingRequest = getBaseSite() pendingRequest = getBaseSite()
.then((res) => { .then((res) => {
@@ -16,7 +19,9 @@ export function loadSiteSettings() {
return settings.value; return settings.value;
}) })
.catch(() => settings.value) .catch(() => settings.value)
.finally(() => { pendingRequest = null; }); .finally(() => {
pendingRequest = null;
});
} }
return pendingRequest; return pendingRequest;
} }

View File

@@ -90,6 +90,11 @@ const reasonList = ref([]); // 投诉原因
function getOrderDetail() { // 获取订单详情 function getOrderDetail() { // 获取订单详情
orderDetail(route.query.sn).then(res => { orderDetail(route.query.sn).then(res => {
if (res.result?.order?.orderStatus === 'CANCELLED') {
Message.warning('订单已取消,无法发起投诉');
router.push({ name: 'MyOrder' });
return;
}
order.value = res.result order.value = res.result
orderGoods.value = res.result.orderItems[route.query.index] orderGoods.value = res.result.orderItems[route.query.index]
orderGoods.value.uploadList = [] orderGoods.value.uploadList = []

View File

@@ -297,7 +297,11 @@
@click="comment(order.order.sn, goodsIndex)" @click="comment(order.order.sn, goodsIndex)"
>评价</button> >评价</button>
<el-button <el-button
v-if="goods.complainStatus == 'NO_APPLY'" v-if="
order.order?.orderStatus !== 'CANCELLED' &&
order.order?.payStatus === 'PAID' &&
goods.complainStatus == 'NO_APPLY'
"
@click="complain(order.order.sn, goodsIndex)" @click="complain(order.order.sn, goodsIndex)"
type="danger" type="danger"
size="small" size="small"
@@ -306,6 +310,7 @@
<el-button <el-button
v-if=" v-if="
!isECouponOrder && !isECouponOrder &&
order.order?.orderStatus !== 'CANCELLED' &&
(goods.afterSaleStatus.includes('NOT_APPLIED') || (goods.afterSaleStatus.includes('NOT_APPLIED') ||
goods.afterSaleStatus.includes('PART_AFTER_SALE')) goods.afterSaleStatus.includes('PART_AFTER_SALE'))
" "

View File

@@ -69,12 +69,19 @@ export function normalizeTheme(data = {}) {
}; };
} }
let pendingThemeRequest = null;
export async function fetchAndApplyTheme(requestFn) { export async function fetchAndApplyTheme(requestFn) {
const cachedTheme = loadCachedTheme(); const cachedTheme = loadCachedTheme();
if (cachedTheme) { if (cachedTheme) {
applyThemeToDocument(normalizeTheme(cachedTheme)); applyThemeToDocument(normalizeTheme(cachedTheme));
} }
if (pendingThemeRequest) {
return pendingThemeRequest;
}
pendingThemeRequest = (async () => {
try { try {
const res = await requestFn(); const res = await requestFn();
if (res?.success && res?.result?.settingValue) { if (res?.success && res?.result?.settingValue) {
@@ -89,7 +96,12 @@ export async function fetchAndApplyTheme(requestFn) {
"[theme] 主题色加载失败,请确认 buyer-api 已启动且站点主题配置可用", "[theme] 主题色加载失败,请确认 buyer-api 已启动且站点主题配置可用",
error error
); );
} finally {
pendingThemeRequest = null;
} }
return cachedTheme ? normalizeTheme(cachedTheme) : normalizeTheme(); return cachedTheme ? normalizeTheme(cachedTheme) : normalizeTheme();
})();
return pendingThemeRequest;
} }

View File

@@ -1,4 +1,5 @@
import { post, get, upload, del } from "@/utils/request"; import { post, get, upload, del } from "@/utils/request";
import config from "@/config/config";
// 获取聊天列表服务接口 // 获取聊天列表服务接口
export const ServeGetTalkList = (data) => { export const ServeGetTalkList = (data) => {
@@ -69,9 +70,9 @@ export const ServeSendTalkFile = (data) => {
return post("/im/talk/message/file", data); return post("/im/talk/message/file", data);
}; };
// 发送聊天图片服务接口 // 上传客服图片到独立 OSS;FormData 必须包含会话 ID,店铺及目录由后端推导。
export const ServeSendTalkImage = (data) => { export const ServeSendTalkImage = (data) => {
return upload("/buyer/common/upload/file", data); return upload("/im/message/image", data, { apiBase: config.BASE_API_URL });
}; };
// 发送表情包服务接口 // 发送表情包服务接口

View File

@@ -1,11 +1,11 @@
<template> <template>
<div class="image-message no-select"> <div class="image-message no-select">
<el-image <el-image
fit="cover" fit="contain"
:src="src" :src="src"
:lazy="true"
:style="getImgStyle(src)" :style="getImgStyle(src)"
:preview-src-list="[src]" :preview-src-list="[src]"
preview-teleported
> >
<template #error> <template #error>
<div class="image-slot">图片加载失败...</div> <div class="image-slot">图片加载失败...</div>
@@ -29,6 +29,10 @@ defineProps({
}) })
function getImgStyle(url) { function getImgStyle(url) {
// 商城上传地址不携带尺寸,提供可见的预览区域;原尺寸后缀仍沿用旧展示规则。
if (!/\d+x\d+/.test(url.split('_').pop())) {
return { width: '200px', height: '200px', maxWidth: '100%' }
}
return imgZoom(url, 200) return imgZoom(url, 200)
} }
</script> </script>

View File

@@ -295,7 +295,7 @@ function formatDateToString(date) {
// margin-left: 12px; // margin-left: 12px;
} }
:deep(.el-tabs__nav-scroll) { :deep(.el-tabs__nav-scroll) {
min-width: 362px; min-width: 0;
} }
:deep(.el-tabs__item) { :deep(.el-tabs__item) {
height: 100%; height: 100%;

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex"> <div class="flex">
<el-container class="ov-hidden flex-10 full-height "> <el-container class="ov-hidden flex-10 full-height chat-main">
<PanelHeader ref="panelHeader" :data="params" :online="isOnline" :keyboard="inputEvent" <PanelHeader ref="panelHeader" :data="params" :online="isOnline" :keyboard="inputEvent"
@event="handleHeaderEvent" /> @event="handleHeaderEvent" />
<!-- 主体信息 --> <!-- 主体信息 -->
@@ -65,7 +65,7 @@
<pre v-if="!emojistwo.includes(item.text)" v-html="item.text" /> <pre v-if="!emojistwo.includes(item.text)" v-html="item.text" />
<pre v-if="emojistwo.includes(item.text)" v-html="textReplaceEmoji(item.text)" /> <pre v-if="emojistwo.includes(item.text)" v-html="textReplaceEmoji(item.text)" />
</div> </div>
<ImageMessage v-if="item.messageType === 'PICTURE'" :src="item.text" />
</div> </div>
<div v-if="item.webSocketStatus" class="tips">网络异常发送失败,请重新发送。</div> <div v-if="item.webSocketStatus" class="tips">网络异常发送失败,请重新发送。</div>
@@ -163,7 +163,7 @@
<!-- 页脚信息 --> <!-- 页脚信息 -->
<el-footer class="footer-box" height="180"> <el-footer class="footer-box" height="180">
<template v-if="multiSelect.isOpen === false"> <template v-if="multiSelect.isOpen === false">
<MeEditor @send="submitSendMessage" @keyboard-event="onKeyboardEvent" /> <MeEditor :talk-id="params.talkId" @send="submitSendMessage" @send-image="submitSendImage" @keyboard-event="onKeyboardEvent" />
</template> </template>
<template v-else> <template v-else>
<PanelToolbar v-model="multiSelect.items.length" @event="handleMultiMode" /> <PanelToolbar v-model="multiSelect.items.length" @event="handleMultiMode" />
@@ -185,7 +185,7 @@
</transition> </transition>
<!-- 链接信息 --> <!-- 链接信息 -->
<OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="flex-4" /> <OtherLink :toUser="toUser" :id="id" :goodsParams="goodsParams" class="chat-sidebar" />
</div> </div>
</template> </template>
<script setup> <script setup>
@@ -202,6 +202,7 @@ import { textReplaceEmoji, emojistwo } from "@/utils/emojis";
import OtherLink from "@/components/chat/panel/OtherLink.vue"; import OtherLink from "@/components/chat/panel/OtherLink.vue";
import TalkSearchRecord from "@/components/chat/TalkSearchRecord"; import TalkSearchRecord from "@/components/chat/TalkSearchRecord";
import MeEditor from "@/components/editor/MeEditor"; import MeEditor from "@/components/editor/MeEditor";
import ImageMessage from "@/components/chat/messaege/ImageMessage.vue";
import PanelHeader from "./PanelHeader"; import PanelHeader from "./PanelHeader";
import PanelToolbar from "./PanelToolbar"; import PanelToolbar from "./PanelToolbar";
import SocketInstance from "@/im-server/socket-instance"; import SocketInstance from "@/im-server/socket-instance";
@@ -414,16 +415,21 @@ function formateDateAndTimeToString(date) {
// 重新发送消息 // 重新发送消息
function againSendMessage(val) { function againSendMessage(val) {
submitSendMessage(val.text) submitSendMessage(val.text, val.messageType)
} }
// 回车键发送消息回调事件 // 发送已上传图片,复用消息发送和失败重发流程。
function submitSendMessage(content) { function submitSendImage(url) {
submitSendMessage(url, "PICTURE");
}
// 发送文本或图片,重发保持原类型,发送图片时保留文字草稿。
function submitSendMessage(content, messageType = "MESSAGE") {
const record = { const record = {
operation_type: "MESSAGE", operation_type: "MESSAGE",
to: props.params.receiver_id, to: props.params.receiver_id,
from: id.value, from: id.value,
message_type: "MESSAGE", message_type: messageType,
context: content, context: content,
talk_id: props.params.talkId, talk_id: props.params.talkId,
}; };
@@ -434,7 +440,7 @@ function submitSendMessage(content) {
talkStore.UPDATE_TALK_ITEM({ talkStore.UPDATE_TALK_ITEM({
index_name: index_name.value, index_name: index_name.value,
draft_text: "", ...(messageType === "MESSAGE" ? { draft_text: "" } : {}),
}); });
/** /**
@@ -445,7 +451,8 @@ function submitSendMessage(content) {
fromUser: id.value, fromUser: id.value,
toUser: record.to, toUser: record.to,
isRead: false, isRead: false,
messageType: "MESSAGE", messageType,
talkId: record.talk_id,
text: content, text: content,
float: "right", float: "right",
}; };
@@ -465,7 +472,7 @@ function submitSendMessage(content) {
}); });
} else { } else {
talkStore.SET_TLAK_UNREAD_MESSAGE({ talkStore.SET_TLAK_UNREAD_MESSAGE({
content: content, content: messageType === "PICTURE" ? "[图片]" : content,
nickname: record.name, nickname: record.name,
}); });
} }
@@ -889,6 +896,17 @@ function talkPanelScrollBottom() {
} }
</script> </script>
<style lang="less" scoped> <style lang="less" scoped>
/* 固定侧栏宽度,防止 tab 内容最小宽度挤压聊天区域。 */
.chat-main {
flex: 1 1 0;
min-width: 0;
}
.chat-sidebar {
flex: 0 0 362px;
width: 362px;
min-width: 0;
overflow: hidden;
}
.flex-a-c{ .flex-a-c{
align-items: center; align-items: center;
>div{ >div{

View File

@@ -21,11 +21,11 @@
<legacy-el-icon name="el-icon-headset" /> <legacy-el-icon name="el-icon-headset" />
<p class="tip-title">语音消息</p> <p class="tip-title">语音消息</p>
</li> --> </li> -->
<!-- #TODO 发图片功能暂时隐藏 此处会涉及到token过期 --> <li role="button" tabindex="0" aria-label="发送图片" @click="restFile.click()"
<!-- <li @click="$refs.restFile.click()"> @keydown.enter="restFile.click()" @keydown.space.prevent="restFile.click()">
<legacy-el-icon name="el-icon-picture-outline-round" /> <legacy-el-icon name="el-icon-picture-outline-round" />
<p class="tip-title">图片</p> <p class="tip-title">图片</p>
</li> --> </li>
<!-- <li @click="$refs.restFile2.click()"> <!-- <li @click="$refs.restFile2.click()">
<legacy-el-icon name="el-icon-folder" /> <legacy-el-icon name="el-icon-folder" />
<p class="tip-title">附件</p> <p class="tip-title">附件</p>
@@ -117,8 +117,12 @@ import {
} from "@/api/chat"; } from "@/api/chat";
defineOptions({ name: "MeEditor" }); defineOptions({ name: "MeEditor" });
const props = defineProps({
// 后端 li_im_talk.id;不使用接收方 ID 代替会话 ID,避免图片存入错误会话。
talkId: { type: String, default: "" },
});
const emit = defineEmits(["keyboard-event", "send"]); const emit = defineEmits(["keyboard-event", "send", "send-image"]);
const dialogueStore = useDialogueStore(); const dialogueStore = useDialogueStore();
@@ -141,6 +145,10 @@ const imageViewer = reactive({
isShow: false, isShow: false,
file: null, file: null,
}); });
// 预览绑定原会话,上传期间切换会话后不发送,避免图片误发。
let imageTalk = null;
let imageTalkId = null;
let uploadingImage = false;
const codeBlock = reactive({ const codeBlock = reactive({
isShow: false, isShow: false,
@@ -165,6 +173,8 @@ const sendtime = ref(0);
const interval = ref(1000); const interval = ref(1000);
watch(talkUser, (n_index_name) => { watch(talkUser, (n_index_name) => {
imageViewer.isShow = false;
imageViewer.file = null;
fileManageRef.value?.clear(); fileManageRef.value?.clear();
editorText.value = getDraftText(n_index_name); editorText.value = getDraftText(n_index_name);
}); });
@@ -253,6 +263,21 @@ function uploadFileChange(e) {
// 打开图片查看器 // 打开图片查看器
function openImageViewer(file) { function openImageViewer(file) {
if (!file || uploadingImage) return;
if (!props.talkId) {
ElNotification.warning({ message: "会话尚未就绪,请稍后重试" });
return;
}
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type) || !file.size) {
ElNotification.warning({ message: "请选择有效的 PNG、JPG、GIF 或 WebP 图片" });
return;
}
if (file.size > 10 * 1024 * 1024) {
ElNotification.warning({ message: "图片不能大于10MB" });
return;
}
imageTalk = talkUser.value;
imageTalkId = props.talkId;
imageViewer.isShow = true; imageViewer.isShow = true;
imageViewer.file = file; imageViewer.file = file;
} }
@@ -278,29 +303,34 @@ function confirmCodeBlock(data) {
}); });
} }
// 确认上传图片消息回调事件 // 上传返回地址后通过现有消息通道发送;失败保留预览以便重试。
function confirmUploadImage() { async function confirmUploadImage() {
let fileData = new FormData(); if (uploadingImage) return;
const imageView = imageViewerRef.value;
const targetTalk = imageTalk;
const targetTalkId = imageTalkId;
uploadingImage = true;
try {
const fileData = new FormData();
fileData.append("file", imageViewer.file); fileData.append("file", imageViewer.file);
fileData.append("talkId", targetTalkId);
let imageView = imageViewerRef.value; const res = await ServeSendTalkImage(fileData);
if (res.code !== 200 || typeof res.result !== "string" || !/^https?:\/\//i.test(res.result)) {
ServeSendTalkImage(fileData) throw new Error(res.message || "图片上传失败,请重试");
.then((res) => {
imageView.loading = false;
if (res.code == 200) {
imageView.closeBox();
} else {
ElNotification({
title: "友情提示",
message: res.message,
type: "warning",
});
} }
}) if (targetTalk !== talkUser.value || targetTalkId !== props.talkId || !imageViewer.isShow) {
.finally(() => { ElNotification.warning({ message: "会话已切换,图片未发送,请在目标会话重新选择" });
return;
}
emit("send-image", res.result);
imageView.loading = false; imageView.loading = false;
}); imageView.closeBox();
} catch (error) {
ElNotification.error({ message: error.message || "图片上传失败,请重试" });
} finally {
uploadingImage = false;
imageView.loading = false;
}
} }
// 选中表情包回调事件 // 选中表情包回调事件

View File

@@ -62,7 +62,10 @@ function closeBox() {
emit('close', false) emit('close', false)
} }
// 清理切换会话后的预览,仅对有效文件读取内容。
function loadFile(file) { function loadFile(file) {
src.value = ''
if (!file) return
let reader = new FileReader() let reader = new FileReader()
fileSize.value = Math.ceil(file.size / 1024) fileSize.value = Math.ceil(file.size / 1024)
fileName.value = file.name fileName.value = file.name
@@ -75,6 +78,7 @@ function loadFile(file) {
// 确认按钮事件 // 确认按钮事件
function uploadImage() { function uploadImage() {
if (loading.value || !src.value) return
loading.value = true loading.value = true
emit('confirm') emit('confirm')
} }

View File

@@ -1,4 +1,5 @@
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useDialogueStore } from "@/stores/dialogue";
import config from "@/config/config"; import config from "@/config/config";
import WsSocket from "@/plugins/ws-socket"; import WsSocket from "@/plugins/ws-socket";
import { getToken } from "@/utils/auth"; import { getToken } from "@/utils/auth";
@@ -86,6 +87,15 @@ class SocketInstance {
}); });
this.socket.on("event_error", (data) => { this.socket.on("event_error", (data) => {
// 将被服务端拒绝的图片标记为失败,避免乐观展示被误认为发送成功。
if (data.pictureUrl) {
useDialogueStore().records.forEach((record) => {
if (record.messageType === "PICTURE" && record.float === "right"
&& record.talkId === data.talkId && record.text === data.pictureUrl) {
record.webSocketStatus = true;
}
});
}
ElNotification({ ElNotification({
title: "友情提示", title: "友情提示",
message: data.message, message: data.message,

View File

@@ -272,6 +272,12 @@ class WsSocket {
* @param {Object} evt Websocket 消息 * @param {Object} evt Websocket 消息
*/ */
onMessage(evt) { onMessage(evt) {
// 图片权限校验失败属于系统反馈,不能当成聊天消息插入记录。
const envelope = JSON.parse(evt.data);
if (envelope.messageResultType === "SYSTEM_TIPS") {
this.onCallBacks["event_error"]?.(envelope.result);
return;
}
let result = this.onParse(evt); let result = this.onParse(evt);
// 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传 // 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
const text = result.text; const text = result.text;

View File

@@ -153,10 +153,12 @@ export const del = (url, data = {}, options = {}) => {
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
export const upload = (url, data = {}, options = {}) => { export const upload = (url, data = {}, options = {}) => {
// 默认沿用买家上传地址;客服图片显式选择 IM 服务,不将文件发往通用上传入口。
const { apiBase = config.BASE_BUYER, ...requestOptions } = options;
return request({ return request({
url: config.BASE_BUYER + url, url: apiBase + url,
method: "post", method: "post",
data: data, data: data,
...options, ...requestOptions,
}); });
}; };

View File

@@ -78,6 +78,15 @@ export const getOrderList = (params) => {
return getRequest(`/order/order`, params) return getRequest(`/order/order`, params)
} }
// 小程序专用接口由服务端固定来源与微信支付范围,并回填本地上报结果。
export const getWechatOrderList = (params) => getRequest('/order/order/wechat', params)
// 状态统计与专用列表共用上报状态筛选。
export const getWechatOrderNum = (params) => getRequest('/order/order/wechat/orderNum', params)
// 人工补报复用支付流水锁和真实履约校验,不受自动上报开关限制。
export const retryWechatOrder = (sn) => postRequest(`/order/order/wechat/${encodeURIComponent(sn)}/retry`, {}, undefined, 120000)
// 查询同笔微信支付下的操作日志,不返回原始支付和物流凭据。
export const getWechatOrderLogs = (sn, params) => getRequest(`/order/order/wechat/${encodeURIComponent(sn)}/logs`, params)
// 导出订单列表 // 导出订单列表
export const exportOrder = (params) => { export const exportOrder = (params) => {
return getRequest(`/order/order/queryExportOrder`, params,'blob') return getRequest(`/order/order/queryExportOrder`, params,'blob')

View File

@@ -182,10 +182,12 @@ export const postBlobRequest = (url, data) => {
}); });
}; };
export const postRequest = (url, params, headers) => { // 个别需等待外部平台的操作可传超时毫秒数,现有调用保持 8 秒默认值。
export const postRequest = (url, params, headers, timeout = 8000) => {
let accessToken = getStore("accessToken"); let accessToken = getStore("accessToken");
return service({ return service({
method: "post", method: "post",
timeout,
url: `${url}`, url: `${url}`,
data: params, data: params,
transformRequest: headers transformRequest: headers

View File

@@ -0,0 +1,23 @@
/**
* 小程序订单上报状态及日志显示口径,成功仅表示发货接口受理。
* @author Bulbasaur
* @since 2026-11-09
*/
export const wechatSyncOptions = [
{ value: "UNCHECKED", label: "待核查", type: "info" },
{ value: "PENDING", label: "待上报", type: "warning" },
{ value: "PROCESSING", label: "处理中", type: "warning" },
{ value: "SUCCESS", label: "上报成功", type: "success" },
{ value: "FAILED", label: "上报失败", type: "danger" },
];
/** 查找页面和日志的统一状态标签,重复请求跳过使用独立说明。 */
export function wechatSyncLabel(value) {
if (value === "SKIPPED") return "已跳过重复";
return wechatSyncOptions.find((item) => item.value === value)?.label || value || "—";
}
/** 返回 Element Plus 标签类型,未知状态使用中性色。 */
export function wechatSyncTag(value) {
return wechatSyncOptions.find((item) => item.value === value)?.type || "info";
}

View File

@@ -52,6 +52,7 @@
@change="selectDateRange" @change="selectDateRange"
/> />
</el-form-item> </el-form-item>
<slot name="extra-filters" :form="searchForm" />
<el-form-item class="order-search-action"> <el-form-item class="order-search-action">
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button> <el-button @click="handleReset">重置</el-button>
@@ -74,6 +75,9 @@
<el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button> <el-button type="warning" class="export" :loading="exporting" @click="exportOrder">导出订单</el-button>
</div> </div>
<el-alert v-if="loadError" title="订单加载失败,请重试" type="error" :closable="false" show-icon>
<el-button link type="primary" @click="init">重新加载</el-button>
</el-alert>
<el-table v-loading="loading" :data="data" ref="tableRef" class="mt_10" style="width: 100%"> <el-table v-loading="loading" :data="data" ref="tableRef" class="mt_10" style="width: 100%">
<el-table-column prop="sn" label="订单号" min-width="200" show-overflow-tooltip /> <el-table-column prop="sn" label="订单号" min-width="200" show-overflow-tooltip />
<el-table-column label="商品" min-width="280"> <el-table-column label="商品" min-width="280">
@@ -126,9 +130,11 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="createTime" label="下单时间" width="170" /> <el-table-column prop="createTime" label="下单时间" width="170" />
<el-table-column label="操作" width="80" align="center" fixed="right"> <slot name="extra-columns" />
<el-table-column label="操作" :width="actionWidth" align="center" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<a class="link-text" @click="detail(row)">查看</a> <a class="link-text" @click="detail(row)">查看</a>
<slot name="extra-actions" :row="row" />
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -162,6 +168,8 @@ const props = defineProps({
fixedClientType: { type: String, default: "" }, fixedClientType: { type: String, default: "" },
fixedPaymentMethod: { type: String, default: "" }, fixedPaymentMethod: { type: String, default: "" },
allowExport: { type: Boolean, default: true }, allowExport: { type: Boolean, default: true },
wechatSync: { type: Boolean, default: false },
actionWidth: { type: Number, default: 80 },
}); });
const PROMOTION_OPTIONS = [ const PROMOTION_OPTIONS = [
@@ -204,6 +212,7 @@ function emptySearchForm(pageSize = 20) {
orderStatus: "", orderStatus: "",
paymentMethod: props.fixedPaymentMethod, paymentMethod: props.fixedPaymentMethod,
orderPromotionType: "", orderPromotionType: "",
...(props.wechatSync ? { wechatSyncStatus: "" } : {}),
}; };
} }
@@ -216,6 +225,9 @@ const searchFormRef = ref(null);
const tableRef = ref(null); const tableRef = ref(null);
const loading = ref(true); const loading = ref(true);
const loadError = ref(false);
let listRequestSeq = 0;
let countRequestSeq = 0;
const exporting = ref(false); const exporting = ref(false);
const pageSizes = ref(PAGE_SIZES); const pageSizes = ref(PAGE_SIZES);
const promotionOptions = ref(PROMOTION_OPTIONS); const promotionOptions = ref(PROMOTION_OPTIONS);
@@ -389,16 +401,28 @@ function listParams() {
return params; return params;
} }
function getDataList() { function getDataList() {
const requestId = ++listRequestSeq;
loading.value = true; loading.value = true;
API_Order.getOrderList(listParams()) loadError.value = false;
const fetchList = props.wechatSync ? API_Order.getWechatOrderList : API_Order.getOrderList;
fetchList(listParams())
.then((res) => { .then((res) => {
if (requestId !== listRequestSeq) return;
if (res.success) { if (res.success) {
data.value = res.result.records; data.value = res.result.records;
total.value = res.result.total; total.value = res.result.total;
} else {
throw new Error("订单查询失败");
} }
}) })
.catch(() => {
if (requestId !== listRequestSeq) return;
data.value = [];
total.value = 0;
loadError.value = true;
})
.finally(() => { .finally(() => {
loading.value = false; if (requestId === listRequestSeq) loading.value = false;
}); });
} }
function detail(v) { function detail(v) {
@@ -454,14 +478,19 @@ function orderStatusClick(name) {
getDataList(); getDataList();
} }
function getOrderNumData() { function getOrderNumData() {
API_Order.getOrderNum(countParams()) const requestId = ++countRequestSeq;
const fetchCount = props.wechatSync ? API_Order.getWechatOrderNum : API_Order.getOrderNum;
fetchCount(countParams())
.then((res) => { .then((res) => {
if (requestId !== countRequestSeq) return;
if (res.success) { if (res.success) {
orderNumData.value = res.result; orderNumData.value = res.result;
} else {
orderNumData.value = {};
} }
}) })
.catch(() => { .catch(() => {
orderNumData.value = {}; if (requestId === countRequestSeq) orderNumData.value = {};
}); });
} }
@@ -476,6 +505,9 @@ watch(
} }
); );
// 补报完成后刷新当前筛选和页码,不清空运营人员已选条件。
defineExpose({ reload: init });
onMounted(() => { onMounted(() => {
try { try {
sessionStorage.removeItem("manager-order-list-query"); sessionStorage.removeItem("manager-order-list-query");

View File

@@ -0,0 +1,88 @@
<!--
小程序订单同步日志抽屉:按支付流水展示审计记录,不显示原始身份或支付凭据。
@author Bulbasaur
@since 2026-11-09
-->
<template>
<el-drawer :model-value="modelValue" title="微信订单同步日志" size="min(1100px, 95vw)" @update:model-value="emit('update:modelValue', $event)">
<p>订单:{{ orderSn }}。日志包含同笔微信支付下的关联子订单操作;上报成功仅代表微信接口已受理。</p>
<el-button :loading="loading" @click="load">刷新</el-button>
<el-alert v-if="failed" title="日志加载失败,请点击刷新重试" type="error" :closable="false" class="mt_10" />
<el-table v-loading="loading" :data="records" class="mt_10" empty-text="暂无本地同步记录,历史上报结果需核查">
<el-table-column type="expand">
<template #default="{ row }">
<div class="log-detail">
<p>触发订单:{{ row.orderSn }}</p>
<p>请求摘要:{{ row.summary || '—' }}</p>
<p>错误码:{{ row.errorCode || '—' }}</p>
<p>处理原因:{{ row.errorMessage || '—' }}</p>
<p>结束时间:{{ row.finishTime || '—' }};耗时:{{ row.durationMs == null ? '—' : `${row.durationMs} 毫秒` }}</p>
</div>
</template>
</el-table-column>
<el-table-column prop="createTime" label="操作时间" width="175" />
<el-table-column label="操作" width="110">
<template #default="{ row }">{{ row.operation === 'RECEIVE' ? '收货通知' : '发货上报' }}</template>
</el-table-column>
<el-table-column label="来源" width="110">
<template #default="{ row }">{{ row.triggerType === 'MANUAL' ? '人工补报' : '自动事件' }}</template>
</el-table-column>
<el-table-column prop="operatorName" label="操作人" min-width="100" />
<el-table-column label="结果" min-width="120">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.status)">{{ wechatSyncLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="errorMessage" label="处理原因" min-width="220" show-overflow-tooltip />
</el-table>
<el-pagination v-model:current-page="pageNumber" :page-size="20" :total="total" layout="total, prev, pager, next" class="mt_10" @current-change="load" />
</el-drawer>
</template>
<script setup>
import { ref, watch } from "vue";
import { getWechatOrderLogs } from "@/api/order";
import { wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
const props = defineProps({ modelValue: Boolean, orderSn: { type: String, default: "" } });
const emit = defineEmits(["update:modelValue"]);
const records = ref([]);
const loading = ref(false);
const failed = ref(false);
const total = ref(0);
const pageNumber = ref(1);
let requestSeq = 0;
/** 请求序号隔离不同订单及页码,关闭抽屉后不接受过期响应。 */
async function load() {
if (!props.modelValue || !props.orderSn) return;
const id = ++requestSeq;
loading.value = true;
failed.value = false;
try {
const res = await getWechatOrderLogs(props.orderSn, { pageNumber: pageNumber.value, pageSize: 20 });
if (id !== requestSeq) return;
if (!res.success) throw new Error("日志加载失败");
records.value = res.result.records;
total.value = res.result.total;
} catch (e) {
if (id !== requestSeq) return;
failed.value = true;
records.value = [];
total.value = 0;
} finally {
if (id === requestSeq) loading.value = false;
}
}
watch(() => [props.modelValue, props.orderSn], () => {
requestSeq++;
records.value = [];
total.value = 0;
pageNumber.value = 1;
loading.value = false;
if (props.modelValue) load();
}, { immediate: true });
</script>
<style scoped>
.log-detail { padding: 0 20px; overflow-wrap: anywhere; }
</style>

View File

@@ -8,14 +8,76 @@
class="wechat-order-notice" class="wechat-order-notice"
description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。" description="仅展示微信小程序下单且使用微信支付的订单。自动上报开关开启且微信侧已开通发货管理时,按现有履约流程上报。"
/> />
<OrderList fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" /> <OrderList ref="orderListRef" fixed-client-type="WECHAT_MP" fixed-payment-method="WECHAT" :allow-export="false" wechat-sync :action-width="220">
<template #extra-filters="{ form }">
<el-form-item label="上报状态" prop="wechatSyncStatus">
<el-select v-model="form.wechatSyncStatus" placeholder="全部上报状态" clearable>
<el-option v-for="item in wechatSyncOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</template>
<template #extra-columns>
<el-table-column label="微信上报状态" width="130">
<template #default="{ row }"><el-tag :type="wechatSyncTag(row.wechatSyncStatus)">{{ wechatSyncLabel(row.wechatSyncStatus) }}</el-tag></template>
</el-table-column>
<el-table-column prop="wechatSyncTime" label="最近上报时间" width="175" />
<el-table-column prop="wechatSyncError" label="上报处理原因" min-width="230" show-overflow-tooltip />
</template>
<template #extra-actions="{ row }">
<el-button link type="primary" @click="showLogs(row)">同步日志</el-button>
<el-button link type="warning" :loading="retryingSn === row.sn" :disabled="!!retryingSn" @click="retry(row)">补报</el-button>
</template>
</OrderList>
<SyncLogDrawer v-model="logVisible" :order-sn="logOrderSn" />
</div> </div>
</template> </template>
<script setup> <script setup>
import OrderList from "../order/orderList.vue"; import OrderList from "../order/orderList.vue";
import SyncLogDrawer from "./SyncLogDrawer.vue";
import { ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { retryWechatOrder } from "@/api/order";
import { wechatSyncOptions, wechatSyncLabel, wechatSyncTag } from "@/utils/wechatOrderSync";
defineOptions({ name: "wechatOrderList" }); defineOptions({ name: "wechatOrderList" });
const orderListRef = ref(null);
const retryingSn = ref("");
const logVisible = ref(false);
const logOrderSn = ref("");
/** 展示当前订单所在微信支付流水的审计日志。 */
function showLogs(row) {
logOrderSn.value = row.sn;
logVisible.value = true;
}
/** 人工补报需要明确确认范围;同笔支付共用锁,已成功且内容未变时由后端跳过。 */
async function retry(row) {
if (retryingSn.value) return;
try {
const historyNotice = row.wechatSyncStatus === "UNCHECKED" ? "该订单没有本地上报记录,请先核查微信后台,确认需要补报后继续。" : "";
await ElMessageBox.confirm(historyNotice + "将按该笔微信支付的全部关联订单归集补报。人工补报不受自动上报开关限制;已成功且内容未变化会跳过。", "补报微信发货信息", { type: "warning", confirmButtonText: "确认补报", cancelButtonText: "取消" });
} catch (e) { return; }
if (retryingSn.value) return;
retryingSn.value = row.sn;
try {
const res = await retryWechatOrder(row.sn);
if (!res.success) throw new Error(res.message || "补报失败");
if (res.result === "SUCCESS") ElMessage.success("微信接口已受理发货信息");
else if (res.result === "SKIPPED") ElMessage.success("相同发货信息已成功上报,本次已跳过");
else {
ElMessage.warning("本次未完成上报,请查看同步日志中的处理原因");
showLogs(row);
}
} catch (e) {
ElMessage.error("补报未完成,请刷新状态并查看日志后再处理");
} finally {
retryingSn.value = "";
orderListRef.value?.reload();
}
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -6,6 +6,25 @@
<span class="desc">配置买家端联系客服以及商家端登录客服跳转的路径</span> <span class="desc">配置买家端联系客服以及商家端登录客服跳转的路径</span>
</el-form-item> </el-form-item>
<el-divider content-position="left">客服图片存储</el-divider>
<el-form-item label="OSS Bucket" prop="imageOssBucket">
<el-input v-model.trim="formValidate.imageOssBucket" placeholder="客服图片专用桶名称" />
<span class="desc">使用独立于商品、头像的桶,并在 OSS 控制台设为公共读、私有写。</span>
</el-form-item>
<el-form-item label="OSS Endpoint" prop="imageOssEndpoint">
<el-input v-model.trim="formValidate.imageOssEndpoint" placeholder="oss-cn-beijing.aliyuncs.com" />
<span class="desc">填写公网节点域名,不含 https:// 或路径。</span>
</el-form-item>
<el-form-item label="AccessKey ID" prop="imageOssAccessKeyId">
<el-input v-model.trim="formValidate.imageOssAccessKeyId" autocomplete="off" />
</el-form-item>
<el-form-item label="AccessKey Secret" prop="imageOssAccessKeySecret">
<el-input v-model.trim="formValidate.imageOssAccessKeySecret" type="password" show-password autocomplete="new-password" />
</el-form-item>
<el-form-item>
<span class="desc">客户和客服的图片统一按会话所属店铺存储。支持 PNG、JPG、GIF、WebP,单张不超过10MB。四项均留空时无法发送图片,上传失败不会写入通用桶;持有图片链接即可查看。</span>
</el-form-item>
<div class="label-btns"> <div class="label-btns">
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button> <el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
</div> </div>
@@ -26,7 +45,11 @@ const { proxy } = getCurrentInstance();
// 只做属性合并的成组对象 // 只做属性合并的成组对象
const ruleValidate = reactive({}); // 验证规则 const ruleValidate = reactive({}); // 验证规则
const formValidate = ref({ // 表单数据 const formValidate = ref({ // 表单数据
httpUrl: "" httpUrl: "",
imageOssBucket: "",
imageOssEndpoint: "",
imageOssAccessKeyId: "",
imageOssAccessKeySecret: "",
}); });
const result = ref(""); const result = ref("");
@@ -57,18 +80,30 @@ function setupSetting() {
function init() { function init() {
result.value = JSON.parse(props.res); result.value = JSON.parse(props.res);
formValidate.value = { ...result.value }; formValidate.value = { ...formValidate.value, ...result.value };
Object.keys(formValidate.value).forEach((item) => { ruleValidate.httpUrl = [
ruleValidate[item] = [
{ {
required: true, required: true,
message: "请填写必填项", message: "请填写必填项",
trigger: "blur", trigger: "blur",
}, },
]; ];
["imageOssBucket", "imageOssEndpoint", "imageOssAccessKeyId", "imageOssAccessKeySecret"].forEach((key) => {
ruleValidate[key] = [{ validator: validateImageStorage, trigger: "blur" }];
}); });
} }
// 四项配置可整体留空;填写任一项后必须完整,避免只保存节点或密钥导致上传不可用。
function validateImageStorage(rule, value, callback) {
const keys = ["imageOssBucket", "imageOssEndpoint", "imageOssAccessKeyId", "imageOssAccessKeySecret"];
const values = keys.map((key) => (formValidate.value[key] || "").trim());
if (values.some(Boolean) && !values.every(Boolean)) {
callback(new Error("请完整填写客服图片 OSS 的四项配置"));
return;
}
callback();
}
init(); init();
</script> </script>

View File

@@ -0,0 +1,74 @@
/**
* 小程序订单筛选及异步竞态回归,隔离网络验证旧响应不覆盖新页面。
* @author Bulbasaur
* @since 2026-11-09
*/
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { parse } = require('@vue/compiler-sfc');
const root = path.resolve(__dirname, '..');
const tick = () => new Promise(resolve => setImmediate(resolve));
/** 从实际 Vue 脚本加载逻辑,替换网络及 Vue 生命周期,不启动业务服务。 */
function script(file) {
return parse(fs.readFileSync(path.join(root,file),'utf8')).descriptor.scriptSetup.content.replace(/^import .*;\r?$/gm,'');
}
/** 为并发请求提供可控完成顺序。 */
function deferred() {
let resolve, reject;
const promise = new Promise((yes,no) => {resolve=yes; reject=no;});
return {promise,resolve,reject};
}
/** 保留表单值及计算属性的最小运行环境。 */
function primitives() {
return { ref:value=>({value}), computed:fn=>({get value(){return fn();}}), defineOptions:()=>{}, defineExpose:()=>{}, onMounted:()=>{} };
}
test('专用列表固定支付范围、重置筛选,且旧列表和数量响应不能覆盖新结果', async()=>{
const requests=[];
const send=kind=>params=>{const wait=deferred();requests.push({kind,params:{...params},...wait});return wait.promise;};
const context={...primitives(),defineProps:()=>({fixedClientType:'WECHAT_MP',fixedPaymentMethod:'WECHAT',wechatSync:true,allowExport:false}),useRoute:()=>({query:{clientType:'PC',paymentMethod:'WALLET'}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getWechatOrderList:send('list'),getWechatOrderNum:send('count')}};
vm.createContext(context);
vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,handleSearch,handleReset,changePage,orderStatusClick,searchForm,data,orderNumData,loading,loadError};',context);
const t=context.t;
t.init();
t.searchForm.value.paymentMethod='WALLET';t.searchForm.value.wechatSyncStatus='FAILED';t.handleSearch();
const newest=requests.filter(r=>r.kind==='list').at(-1);
newest.resolve({success:true,result:{records:[{sn:'new'}],total:1}});
requests.filter(r=>r.kind==='count').at(-1).resolve({success:true,result:{waitPayNum:5}});
await tick();
requests[0].resolve({success:true,result:{records:[{sn:'old'}],total:99}});
requests[1].resolve({success:true,result:{waitPayNum:99}});
await tick();
assert.equal(t.data.value[0].sn,'new');assert.equal(t.orderNumData.value.waitPayNum,5);
t.changePage(2);t.orderStatusClick('DELIVERED');t.handleReset();
for(const r of requests){assert.equal(r.params.clientType,'WECHAT_MP');assert.equal(r.params.paymentMethod,'WECHAT');}
assert.equal(t.searchForm.value.wechatSyncStatus,'');assert.equal(t.searchForm.value.paymentMethod,'WECHAT');
requests.filter(r=>r.kind==='list').at(-1).reject(new Error('network'));
await tick();assert.equal(t.loadError.value,true);assert.equal(t.data.value.length,0);assert.equal(t.loading.value,false);
});
test('普通订单仍使用原接口且允许其他支付方式,不强制附加上报条件', async()=>{
let sent;
const context={...primitives(),defineProps:()=>({fixedClientType:'',fixedPaymentMethod:'',wechatSync:false,allowExport:true}),useRoute:()=>({query:{}}),watch:()=>{},wechatSyncOptions:[],API_Order:{getOrderList:params=>{sent={...params};return Promise.resolve({success:true,result:{records:[],total:0}});},getOrderNum:()=>Promise.resolve({success:true,result:{}})}};
vm.createContext(context);vm.runInContext(script('src/views/order/order/orderList.vue')+';globalThis.t={init,searchForm};',context);
context.t.searchForm.value.paymentMethod='WALLET';context.t.init();await tick();
assert.equal(sent.paymentMethod,'WALLET');assert.equal(sent.clientType,undefined);assert.equal(sent.wechatSyncStatus,undefined);
});
test('日志抽屉切换订单及关闭后,旧请求不会回填另一笔订单', async()=>{
const props={modelValue:true,orderSn:'A'},requests=[];let changed;
const context={...primitives(),defineProps:()=>props,defineEmits:()=>()=>{},watch:(source,callback)=>{changed=callback;callback();},getWechatOrderLogs:(sn,params)=>{const wait=deferred();requests.push({sn,...wait});return wait.promise;}};
vm.createContext(context);vm.runInContext(script('src/views/order/wechat/SyncLogDrawer.vue')+';globalThis.t={records,loading,failed,load};',context);
props.orderSn='B';changed();
requests[1].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
requests[0].resolve({success:true,result:{records:[{orderSn:'A'}],total:1}});await tick();
assert.equal(context.t.records.value[0].orderSn,'B');
context.t.load();props.modelValue=false;changed();requests[2].resolve({success:true,result:{records:[{orderSn:'B'}],total:1}});await tick();
assert.equal(context.t.records.value.length,0);assert.equal(context.t.loading.value,false);
});