19 Commits

Author SHA1 Message Date
7aa8c72005 feat: 酒店的日期选择调整 2026-04-13 22:33:17 +08:00
19b6b5b321 feat: 酒店部展示核销列表 2026-04-13 21:44:45 +08:00
f632e9c821 feat: 下单预约的日期动态选择 2026-04-13 21:43:11 +08:00
94ab8c5c04 feat: aigc 2026-04-13 20:38:22 +08:00
37ea4ee284 feat: 预约时间调整 2026-04-13 20:20:44 +08:00
134f73d7b1 feat: 非酒店类型的处理 2026-04-13 15:31:57 +08:00
15687566ab feat: toast 问题 2026-04-12 12:30:17 +08:00
duanshuwen
349e2a8f3d feat: 修复toast异常问题 2026-04-11 18:00:32 +08:00
677333cc5f feat: 给toast 添加延迟 2026-04-07 21:03:33 +08:00
13d81f602a feat: aigc 的地址 2026-04-07 20:22:20 +08:00
620456e9bf feat: 只有酒店的时候才动态添加 用户信息 2026-04-07 18:44:43 +08:00
350ce56767 feat: 登录失效之后再次跳转到登录的页面 2026-04-07 18:16:48 +08:00
7ced6a0850 feat: 文本换行问题处理 2026-04-07 18:10:35 +08:00
c9f350b157 feat: 消息流中的图片样式处理 2026-04-07 12:19:15 +08:00
605183e8dc feat: 消息输出时的滚动优化 2026-04-06 15:10:57 +08:00
8884529b91 feat: 参数的拼接 2026-04-06 13:58:06 +08:00
1f75a7eab8 feat: web通讯的处理 2026-04-06 01:14:33 +08:00
c29330e03a feat: 消息的初始化处理 2026-04-05 23:13:46 +08:00
8f713a64c7 feat: 处理加载H5页面的 方法逻辑 2026-04-05 16:49:12 +08:00
21 changed files with 780 additions and 308 deletions

View File

@@ -50,7 +50,7 @@
}
},
"tianmu": {
"clientId": "9",
"clientId": "4",
"appId": "wx0be424e1d22065a9",
"name": "沐沐",
"placeholder": "快告诉沐沐您在想什么~",

View File

@@ -50,7 +50,13 @@
{{ dateInfo.label }}
</text>
<text class="date-number">{{ dateInfo.day }}</text>
<text class="date-price" v-if="dateInfo.price !== null && dateInfo.price !== undefined">¥{{ dateInfo.price }}</text>
<text
class="date-price"
v-if="
dateInfo.price !== null && dateInfo.price !== undefined
"
>¥{{ dateInfo.price }}</text
>
</template>
</view>
</view>
@@ -253,7 +259,7 @@ const generateCalendarGrid = (year, month) => {
// 填充日期
for (let day = 1; day <= daysInMonth; day++) {
const dateStr = `${year}-${String(month).padStart(2, "0")}-${String(
day
day,
).padStart(2, "0")}`;
const priceItem = getPriceItem(dateStr);
grid.push({
@@ -344,7 +350,11 @@ const getDateCellClass = (dateInfo) => {
if (dateInfo.selected) classes.push("date-cell-selected");
if (dateInfo.inRange) classes.push("date-cell-in-range");
// 标注无价格但可选的日期(用于视觉区分)
if (dateInfo.price === null || dateInfo.price === undefined || dateInfo.price === "-") {
if (
dateInfo.price === null ||
dateInfo.price === undefined ||
dateInfo.price === "-"
) {
classes.push("date-cell-no-price");
}
@@ -404,13 +414,19 @@ const handleRangeSelection = (dateInfo) => {
if (!rangeStart.value || (rangeStart.value && rangeEnd.value)) {
// 开始新的范围选择:当作为价格区间选择器时,开始日必须有价格且有库存
if (props.rangeRequirePrice) {
const hasPrice = dateInfo.price !== null && dateInfo.price !== undefined && dateInfo.price !== "-";
const hasPrice =
dateInfo.price !== null &&
dateInfo.price !== undefined &&
dateInfo.price !== "-";
if (!hasPrice) {
uni.showToast({ title: "所选日期不可预订,请重新选择", icon: "none" });
return;
}
if (dateInfo.stock !== undefined && Number(dateInfo.stock) <= 0) {
uni.showToast({ title: "所选日期库存不足,请选择其他日期", icon: "none" });
uni.showToast({
title: "所选日期库存不足,请选择其他日期",
icon: "none",
});
return;
}
}
@@ -421,11 +437,16 @@ const handleRangeSelection = (dateInfo) => {
return;
}
// 否则为结束日期(完成选择)——结束日允许无价格(如为紧接有价日的下一天),但区间内的夜晚必须有价格
// 否则为结束日期(完成选择)
if (rangeStart.value === dateInfo.date) {
uni.showToast({ title: "离店日期不能与入住日期相同", icon: "none" });
return;
}
rangeEnd.value = dateInfo.date;
isRangeSelecting.value = false;
// 允许选择相同日期,但确保开始日期不大于结束日期
// 确保开始日期不大于结束日期
if (new Date(rangeStart.value) > new Date(rangeEnd.value)) {
[rangeStart.value, rangeEnd.value] = [rangeEnd.value, rangeStart.value];
}
@@ -433,7 +454,11 @@ const handleRangeSelection = (dateInfo) => {
// 检查日期跨度是否超过28天
const daysBetween = calculateDaysBetween(rangeStart.value, rangeEnd.value);
if (daysBetween > 28) {
uni.showToast({ title: "预定时间不能超过28天", icon: "none", duration: 3000 });
uni.showToast({
title: "预定时间不能超过28天",
icon: "none",
duration: 3000,
});
rangeStart.value = null;
rangeEnd.value = null;
isRangeSelecting.value = false;
@@ -443,9 +468,14 @@ const handleRangeSelection = (dateInfo) => {
// 如果作为价格区间选择器,验证夜晚(不包含离店日)是否都有价格/库存
if (props.rangeRequirePrice) {
const nights = generateNightsRange(rangeStart.value, rangeEnd.value);
const missing = nights.find((d) => d.price === null || d.price === undefined || d.price === "-");
const missing = nights.find(
(d) => d.price === null || d.price === undefined || d.price === "-",
);
if (missing) {
uni.showToast({ title: "所选区间包含无价格日期,请重新选择", icon: "none" });
uni.showToast({
title: "所选区间包含无价格日期,请重新选择",
icon: "none",
});
rangeStart.value = null;
rangeEnd.value = null;
return;
@@ -456,7 +486,10 @@ const handleRangeSelection = (dateInfo) => {
return item && item.stock !== undefined && Number(item.stock) <= 0;
});
if (badStock) {
uni.showToast({ title: "所选区间包含库存不足的日期,请重新选择", icon: "none" });
uni.showToast({
title: "所选区间包含库存不足的日期,请重新选择",
icon: "none",
});
rangeStart.value = null;
rangeEnd.value = null;
return;
@@ -551,7 +584,7 @@ watch(
popup.value?.close();
}
},
{ immediate: true }
{ immediate: true },
);
// 生命周期钩子

View File

@@ -1,22 +1,25 @@
<template>
<view class="bg-white rounded-12 overflow-hidden mb-12">
<view
class="border-box font-size-16 font-500 color-000 line-height-24 p-12"
<view class="border-box font-size-16 font-500 color-000 line-height-24 p-12"
>使用日期</view
>
<view class="flex flex-items-center border-box ">
<view class="flex flex-items-center border-box">
<scroll-view class="date-scroll" scroll-x>
<view class="date-list">
<block v-for="(item) in dates" :key="item.date">
<block v-for="item in openDateRangeList" :key="item.date">
<view
class="date-item"
:class="{ selected: isSameDate(selectedDate, item.date) }"
@click="onDateClick(item)"
>
<view class="label font-size-12">{{ item.label }}</view>
<view class="md font-size-16 font-600">{{ formatMD(item.date) }}</view>
<view class="status font-size-12">可订</view>
<view v-if="isSameDate(selectedDate, item.date)" class="check"></view>
<view class="label font-size-12">{{ item.weekDesc }}</view>
<view class="md font-size-16 font-600">{{
formatMD(item.date)
}}</view>
<view class="status font-size-12">{{ item.canOrder }}</view>
<view v-if="isSameDate(selectedDate, item.date)" class="check"
></view
>
</view>
</block>
</view>
@@ -26,50 +29,35 @@
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, watch } from "vue";
const props = defineProps({
selectedDate: { type: String, default: null },
days: { type: Number, default: 14 }
openDateRangeList: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:selectedDate']);
const dates = ref([]);
const emit = defineEmits(["update:selectedDate"]);
const selectedDate = ref(props.selectedDate);
watch(() => props.selectedDate, (v) => {
selectedDate.value = v;
});
const initDates = (days = props.days) => {
const arr = [];
const today = new Date();
for (let i = 0; i < days; i++) {
const d = new Date(today);
d.setDate(today.getDate() + i);
const iso = d.toISOString().slice(0, 10);
arr.push({ date: iso, day: i, disabled: false, label: getLabel(i, d) });
}
dates.value = arr;
}
const getLabel = (i, d) => {
if (i === 0) return '今天';
if (i === 1) return '明天';
const week = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return week[d.getDay()];
}
const formatMD = (dateStr) => {
const d = new Date(dateStr);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${mm}-${dd}`;
}
watch(
() => props.selectedDate,
(v) => {
selectedDate.value = v;
},
);
const isSameDate = (a, b) => {
if (!a || !b) return false;
return a === b;
}
};
// 格式化展示日期,将 2026-04-13 转换为 04-13
const formatMD = (dateStr) => {
if (!dateStr || typeof dateStr !== "string") return "";
const parts = dateStr.split("-");
if (parts.length >= 3) {
return `${parts[1]}-${parts[2]}`;
}
return dateStr;
};
const onDateClick = (item) => {
const date = item.date;
@@ -78,11 +66,11 @@ const onDateClick = (item) => {
} else {
selectedDate.value = date;
}
emit('update:selectedDate', selectedDate.value);
}
onMounted(() => initDates());
console.log("selectedDate:", selectedDate.value);
emit("update:selectedDate", selectedDate.value);
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>
</style>

View File

@@ -48,7 +48,7 @@ export const CompName = {
/// 发送的指令类型
export const Command = {
// 通知消息
welcome: "Command.welcome",
messageInit: "Command.init",
// 快速预定
quickBooking: "Command.quickBooking",
// 探索发现

View File

@@ -16,7 +16,12 @@
</view>
</view>
<UseDateRange v-model:selectedDate="reservationDate"/>
<!-- 使用日期 -->
<UseDateRange
v-if="orderData.reservationEnabled"
:openDateRangeList="orderData.openDateRangeList"
v-model:selectedDate="reservationDate"
/>
<!-- 联系方式 -->
<view class="bg-white rounded-12 overflow-hidden">
@@ -57,6 +62,10 @@ const props = defineProps({
type: String,
default: null,
},
orderData: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["update:modelValue", "update:reservationDate"]);

View File

@@ -29,7 +29,9 @@
</view>
<view class="border-box border-bottom">
<view class="font-size-12 color-99A0AE line-height-16 pb-12">
<view
class="font-size-12 color-99A0AE line-height-16 pb-12 break-all"
>
{{ orderData.commodityDescription }}
</view>
@@ -68,6 +70,7 @@
v-model="quantity"
:userFormList="userFormList"
v-model:reservationDate="selectedReservationDate"
:orderData="orderData"
/>
<!-- 酒店类型 -->
@@ -131,6 +134,9 @@ const isDeleting = ref(false); // 标志位防止删除时watch冲突
watch(
quantity,
async (newQuantity) => {
// 只有在酒店类型orderType == 0时才动态调整 userFormList
if (orderData.value.orderType !== 0) return;
// 如果正在执行删除操作跳过watch逻辑
if (isDeleting.value) {
isDeleting.value = false;
@@ -206,109 +212,116 @@ const validateUserForms = () => {
// 处理支付点击事件
const handlePayClick = ThrottleUtils.createThrottle(async (goodsData) => {
try {
console.log("处理支付点击事件", userFormList.value);
// 校验用户姓名
if (!validateUserForms()) {
console.log("处理支付点击事件", userFormList.value);
// 预约日期,酒店类型不需要
if (orderData.value.reservationEnabled) {
if (!selectedReservationDate.value) {
uni.showToast({ title: "请选择预约日期", icon: "none" });
return;
}
// 校验手机号
if (!PhoneUtils.validatePhone(userFormList.value[0].contactPhone)) {
uni.showToast({ title: "请输入正确的手机号", icon: "none" });
return;
}
// 购买的商品id
const commodityId = goodsData.commodityId;
// 消费者信息
const consumerInfoEntityList = userFormList.value;
// 购买数量
const purchaseAmount = consumerInfoEntityList.length;
// 支付方式 0-微信 1-支付宝 2-云闪付
const payWay = "0";
// 支付渠道 0-app 1-小程序 2-h5
const paySource = "1";
const params = {
commodityId,
purchaseAmount,
payWay,
paySource,
consumerInfoEntityList
};
// 预约日期,酒店类型不需要
if (orderData.value.orderType != 0) {
params.reservationDate = selectedReservationDate.value;
}
//酒店类型添加入住时间、离店时间
if (goodsData.orderType == 0 && selectedDate.value) {
const { startDate, endDate } = selectedDate.value;
// 入住时间
params.checkInData = startDate;
// 离店时间
params.checkOutData = endDate;
}
// 点击后立即展示 loading
uni.showLoading({ title: "正在提交订单..." });
const res = await orderPay(params);
console.log("确认订单---2:", res);
uni.hideLoading();
// 检查接口返回数据
if (!res || !res.data) {
uni.showToast({ title: res.msg || "订单创建失败,请重试", icon: "none" });
return;
}
const { data } = res;
const { nonceStr, packageVal, paySign, signType, timeStamp } = data;
// 验证支付参数是否完整
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
uni.hideLoading();
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
return;
}
// 在发起微信支付前关闭 loading避免与原生支付 UI 冲突)
uni.hideLoading();
// 调用微信支付
uni.requestPayment({
provider: "wxpay",
timeStamp: String(timeStamp), // 确保为字符串类型
nonceStr: String(nonceStr),
package: String(packageVal), // 确保为字符串类型
signType: String(signType),
paySign: String(paySign),
success: () => {
uni.showToast({
title: "支付成功",
icon: "success",
success: () => {
uni.navigateTo({
url: "/pages-order/order/list",
});
},
});
},
fail: (e) => {
console.error("支付失败:", e);
uni.showToast({ title: "支付失败,请重试", icon: "none" });
},
});
} catch (error) {
console.error(error);
uni.showToast({ title: "请求出错,请重试", icon: "none" });
} finally {
// 防止某些分支忘记 hide确保最终关闭 loadingrequestPayment 后也可以安全调用 hide
uni.hideLoading();
}
// 校验用户姓名
if (!validateUserForms()) {
return;
}
// 校验手机号
if (!PhoneUtils.validatePhone(userFormList.value[0].contactPhone)) {
uni.showToast({ title: "请输入正确的手机号", icon: "none" });
return;
}
// 购买的商品id
const commodityId = goodsData.commodityId;
// 消费者信息
const consumerInfoEntityList = userFormList.value;
// 购买数量
const purchaseAmount = quantity.value;
// 支付方式 0-微信 1-支付宝 2-云闪付
const payWay = "0";
// 支付渠道 0-app 1-小程序 2-h5
const paySource = "1";
const params = {
commodityId,
purchaseAmount,
payWay,
paySource,
consumerInfoEntityList,
};
// 预约日期,酒店类型不需要
if (orderData.value.reservationEnabled) {
params.reservationDate = selectedReservationDate.value;
}
//酒店类型添加入住时间、离店时间
if (goodsData.orderType == 0 && selectedDate.value) {
const { startDate, endDate } = selectedDate.value;
// 入住时间
params.checkInData = startDate;
// 离店时间
params.checkOutData = endDate;
}
// 点击后立即展示 loading
uni.showLoading({ title: "正在提交订单..." });
const res = await orderPay(params);
console.log("确认订单---2:", res);
uni.hideLoading();
// 检查接口返回数据
if (!res || !res.data) {
uni.hideLoading();
setTimeout(() => {
uni.showToast({
title: res.msg || "订单创建失败,请重试",
icon: "none",
});
}, 100);
return;
}
const { data } = res;
const { nonceStr, packageVal, paySign, signType, timeStamp } = data;
// 验证支付参数是否完整
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
uni.hideLoading();
setTimeout(() => {
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
}, 100);
return;
}
// 在发起微信支付前关闭 loading避免与原生支付 UI 冲突)
uni.hideLoading();
// 调用微信支付
uni.requestPayment({
provider: "wxpay",
timeStamp: String(timeStamp), // 确保为字符串类型
nonceStr: String(nonceStr),
package: String(packageVal), // 确保为字符串类型
signType: String(signType),
paySign: String(paySign),
success: () => {
uni.showToast({
title: "支付成功",
icon: "success",
success: () => {
uni.navigateTo({
url: "/pages-order/order/list",
});
},
});
},
fail: (e) => {
console.error("支付失败:", e);
uni.showToast({ title: "支付失败,请重试", icon: "none" });
},
});
}, 1000);
</script>

View File

@@ -0,0 +1,32 @@
<template>
</template>
<style scoped>
</style>
<script setup>
import { onMounted } from "vue";
import { saveImageToAlbum } from '@/pages/webview/bridge.js'
onMounted(() => {
// 获取页面参数
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const options = currentPage.options;
if (options.imageUrl) {
const imageUrl = decodeURIComponent(options.imageUrl);
saveImage(imageUrl);
}
});
const saveImage = async (imageUrl) => {
try {
await saveImageToAlbum(imageUrl)
} catch (e) {
}
uni.navigateBack()
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app'
import { chooseAndUploadImage } from '@/pages/webview/bridge.js'
onLoad(() => {
handleChoose()
})
const sendResult = (imageUrl) => {
// 触发全局事件
uni.$emit('UPLOAD_RESULT', imageUrl)
}
const handleChoose = async () => {
try {
const imageUrl = await chooseAndUploadImage()
sendResult(imageUrl)
} catch (e) {
sendResult('error')
}
uni.navigateBack()
}
</script>

View File

@@ -90,7 +90,9 @@ const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
// 检查接口返回数据
if (!res || !res.data) {
uni.hideLoading();
uni.showToast({ title: res.msg || "订单创建失败,请重试", icon: "none" });
setTimeout(() => {
uni.showToast({ title: res.msg || "订单创建失败,请重试", icon: "none" });
}, 100);
return;
}
@@ -100,7 +102,9 @@ const handleButtonClick = DebounceUtils.createDebounce(async (orderData) => {
// 验证支付参数是否完整
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
uni.hideLoading();
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
setTimeout(() => {
uni.showToast({ title: "支付参数错误,请重试", icon: "none" });
}, 100);
return;
}

View File

@@ -5,7 +5,7 @@
<view class="order-detail-wrapper border-box flex-full overflow-hidden scroll-y">
<OrderStatusInfo :orderData="orderData" />
<VoucherList v-if="orderData.orderStatus === '2'" :orderData="orderData" @selected="handleSelectedVoucher" />
<VoucherList v-if="orderData.orderType != 0 && orderData.orderStatus === '2'" :orderData="orderData" @selected="handleSelectedVoucher" />
<AmtSection :orderData="orderData" @click="refundVisible = true" />

View File

@@ -90,6 +90,23 @@
}
}
]
},
{
"root": "pages-bridge",
"pages": [
{
"path": "UploadImage",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "SaveImage",
"style": {
"navigationStyle": "custom"
}
}
]
}
],
"globalStyle": {

View File

@@ -1,7 +1,7 @@
<template>
<view class="container">
<view class="chat-ai">
<view class="loading-container">
<view class="container-content">
<image
v-if="isLoading"
class="loading-img"

View File

@@ -16,7 +16,7 @@
}
}
.loading-container {
.container-content {
display: flex;
align-items: center;
max-width: 100%; // ✅ 限制最大宽度

View File

@@ -2,55 +2,130 @@
<view class="flex flex-col h-screen">
<!-- 顶部自定义导航栏 -->
<view class="header" :style="{ paddingTop: statusBarHeight + 'px' }">
<ChatTopNavBar ref="topNavBarRef" :mainPageDataModel="mainPageDataModel" />
<ChatTopNavBar
ref="topNavBarRef"
:mainPageDataModel="mainPageDataModel"
/>
</view>
<!-- 消息列表可滚动区域 -->
<scroll-view class="main flex-full overflow-hidden scroll-y" scroll-y :scroll-top="scrollTop"
:scroll-with-animation="true" @scroll="handleScroll" @scrolltolower="handleScrollToLower">
<scroll-view
class="main flex-full overflow-hidden scroll-y"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
@scroll="handleScroll"
@scrolltolower="handleScrollToLower"
>
<!-- welcome栏 -->
<ChatTopWelcome ref="welcomeRef" :mainPageDataModel="mainPageDataModel" :welcomeMessage="welcomeMessage" />
<NoticeMessage v-if="notitceConent" :noticeContent="notitceConent"></NoticeMessage>
<ChatTopWelcome ref="welcomeRef" :mainPageDataModel="mainPageDataModel" />
<NoticeMessage
v-if="notitceConent"
:noticeContent="notitceConent"
></NoticeMessage>
<view class="area-msg-list-content" v-for="item in chatMsgList" :key="item.msgId" :id="item.msgId">
<view
class="area-msg-list-content"
v-for="item in chatMsgList"
:key="item.msgId"
:id="item.msgId"
>
<template v-if="item.msgType === MessageRole.AI">
<ChatCardAI class="flex flex-justify-start" :key="`ai-${item.msgId}-${item.msg ? item.msg.length : 0}`"
:text="item.componentName && item.componentName === CompName.longTextCard ? '' : item.msg || ''" :isLoading="item.isLoading">
<template #content v-if="item.toolCall || item.componentName && item.componentName === CompName.longTextCard">
<AnswerComponent v-if=" item.componentName === CompName.longTextCard
" :text="(item.componentMsg || item.msg)" :title="item.title" :finish="item.finish" />
<QuickBookingComponent v-if="
item.toolCall && item.toolCall.componentName === CompName.quickBookingCard
" />
<DiscoveryCardComponent v-else-if="
item.toolCall && item.toolCall.componentName === CompName.discoveryCard
" />
<CreateServiceOrder v-else-if="
item.toolCall && item.toolCall.componentName === CompName.callServiceCard
" :toolCall="item.toolCall" />
<OpenMapComponent v-else-if="
item.toolCall && item.toolCall.componentName === CompName.mapCard
" />
<GeneratorPhotoComponent v-else-if="
item.toolCall && item.toolCall.componentName === CompName.aigcPhotoGeneratorCard
" :toolCall="item.toolCall"/>
<Feedback v-else-if="
item.toolCall && item.toolCall.componentName === CompName.feedbackCard
" :toolCall="item.toolCall" />
<DetailCardCompontent v-else-if="
item.toolCall && item.toolCall.componentName === CompName.pictureAndCommodityCard
" :toolCall="item.toolCall" />
<AddCarCrad v-else-if="
item.toolCall && item.toolCall.componentName === CompName.enterLicensePlateCard
" :toolCall="item.toolCall" />
<SurveyQuestionnaire v-else-if="
item.toolCall && item.toolCall.componentName === CompName.callSurveyQuestionnaire
" :toolCall="item.toolCall" />
<ChatCardAI
class="flex flex-justify-start"
:key="`ai-${item.msgId}-${item.msg ? item.msg.length : 0}`"
:text="
item.componentName && item.componentName === CompName.longTextCard
? ''
: item.msg || ''
"
:isLoading="item.isLoading"
>
<template
#content
v-if="
item.toolCall ||
(item.componentName &&
item.componentName === CompName.longTextCard)
"
>
<AnswerComponent
v-if="item.componentName === CompName.longTextCard"
:text="item.componentMsg || item.msg"
:title="item.title"
:finish="item.finish"
/>
<QuickBookingComponent
v-if="
item.toolCall &&
item.toolCall.componentName === CompName.quickBookingCard
"
/>
<DiscoveryCardComponent
v-else-if="
item.toolCall &&
item.toolCall.componentName === CompName.discoveryCard
"
/>
<CreateServiceOrder
v-else-if="
item.toolCall &&
item.toolCall.componentName === CompName.callServiceCard
"
:toolCall="item.toolCall"
/>
<OpenMapComponent
v-else-if="
item.toolCall &&
item.toolCall.componentName === CompName.mapCard
"
/>
<GeneratorPhotoComponent
v-else-if="
item.toolCall &&
item.toolCall.componentName ===
CompName.aigcPhotoGeneratorCard
"
:toolCall="item.toolCall"
/>
<Feedback
v-else-if="
item.toolCall &&
item.toolCall.componentName === CompName.feedbackCard
"
:toolCall="item.toolCall"
/>
<DetailCardCompontent
v-else-if="
item.toolCall &&
item.toolCall.componentName ===
CompName.pictureAndCommodityCard
"
:toolCall="item.toolCall"
/>
<AddCarCrad
v-else-if="
item.toolCall &&
item.toolCall.componentName === CompName.enterLicensePlateCard
"
:toolCall="item.toolCall"
/>
<SurveyQuestionnaire
v-else-if="
item.toolCall &&
item.toolCall.componentName ===
CompName.callSurveyQuestionnaire
"
:toolCall="item.toolCall"
/>
</template>
<template #footer>
<!-- 这个是底部 -->
<AttachListComponent v-if="item.question" :question="item.question" />
<AttachListComponent
v-if="item.question"
:question="item.question"
/>
</template>
</ChatCardAI>
</template>
@@ -61,15 +136,21 @@
<template v-else>
<ChatCardOther class="flex flex-justify-center" :text="item.msg">
<ActivityListComponent v-if="
mainPageDataModel.activityList &&
mainPageDataModel.activityList.length > 0
" :activityList="mainPageDataModel.activityList" />
<ActivityListComponent
v-if="
mainPageDataModel.activityList &&
mainPageDataModel.activityList.length > 0
"
:activityList="mainPageDataModel.activityList"
/>
<RecommendPostsComponent v-if="
mainPageDataModel.recommendTheme &&
mainPageDataModel.recommendTheme.length > 0
" :recommendThemeList="mainPageDataModel.recommendTheme" />
<RecommendPostsComponent
v-if="
mainPageDataModel.recommendTheme &&
mainPageDataModel.recommendTheme.length > 0
"
:recommendThemeList="mainPageDataModel.recommendTheme"
/>
</ChatCardOther>
</template>
</view>
@@ -78,9 +159,17 @@
<!-- 输入框区域 -->
<view class="pb-safe-area">
<ChatQuickAccess />
<ChatInputArea ref="inputAreaRef" v-model="inputMessage" :holdKeyboard="holdKeyboard"
:is-session-active="isSessionActive" :stop-request="stopRequest" @send="sendMessageAction"
@noHideKeyboard="handleNoHideKeyboard" @keyboardShow="handleKeyboardShow" @keyboardHide="handleKeyboardHide" />
<ChatInputArea
ref="inputAreaRef"
v-model="inputMessage"
:holdKeyboard="holdKeyboard"
:is-session-active="isSessionActive"
:stop-request="stopRequest"
@send="sendMessageAction"
@noHideKeyboard="handleNoHideKeyboard"
@keyboardShow="handleKeyboardShow"
@keyboardHide="handleKeyboardHide"
/>
</view>
</view>
</template>
@@ -135,10 +224,8 @@ const statusBarHeight = ref(20);
const inputAreaRef = ref(null);
const topNavBarRef = ref();
const welcomeRef = ref();
const welcomeMessage = ref("");
const notitceConent = ref(null);
const holdKeyboardTimer = ref(null);
/// focus时点击页面的时候不收起键盘
const holdKeyboard = ref(false);
@@ -152,9 +239,12 @@ const scrollTop = ref(99999);
/// 会话列表
const chatMsgList = ref([]);
/// 输入的输入消息
/// 输入的输入消息
const inputMessage = ref("");
/// 是否自动滚动到底部 (人工手动向上滚动时设为false)
const isAutoScroll = ref(true);
/// agentId 首页接口中获取
const agentId = ref("1");
/// 会话ID 历史数据接口中获取
@@ -170,7 +260,12 @@ let messageCommonType = "";
// WebSocket 相关
let webSocketManager = null;
/// 使用统一的连接状态判断函数,避免状态不同步
const isWsConnected = () => !!(webSocketManager && typeof webSocketManager.isConnected === "function" && webSocketManager.isConnected());
const isWsConnected = () =>
!!(
webSocketManager &&
typeof webSocketManager.isConnected === "function" &&
webSocketManager.isConnected()
);
// pendingMap: messageId -> msgIndex
const pendingMap = new Map();
@@ -209,7 +304,7 @@ const handleKeyboardShow = () => {
holdKeyboard.value = true;
// 键盘弹起时调整聊天内容的底部边距并滚动到底部
setTimeout(() => {
scrollToBottom();
scrollToBottom(true);
}, 150);
};
@@ -221,45 +316,66 @@ const handleKeyboardHide = () => {
// 处理用户滚动事件
const welcomeHeight = ref(0);
const handleScroll = ThrottleUtils.createThrottle(({ detail }) => {
let lastScrollTop = 0;
const handleScroll = (e) => {
const detail = e.detail;
topNavBarRef.value.show = parseInt(detail.scrollTop) > welcomeHeight.value;
}, 50);
const currentScrollTop = detail.scrollTop;
// 如果向上滚动 (当前位置小于上一次记录的位置)
if (currentScrollTop < lastScrollTop - 2) {
// 增加 2px 阈值防止抖动
isAutoScroll.value = false;
}
lastScrollTop = currentScrollTop;
};
// 处理滚动到底部事件
const handleScrollToLower = () => { };
const handleScrollToLower = () => {
// 当用户滚动到底部时,明确开启自动滚动
isAutoScroll.value = true;
};
// 滚动到底部 - 优化版本,确保打字机效果始终可见
const scrollToBottom = () => {
const scrollToBottom = (force = false) => {
// 如果当前不是自动滚动模式且非强制触发,则不执行滚动
if (!isAutoScroll.value && !force) {
console.log("暂停自动滚动,当前位置:", scrollTop.value);
return;
}
nextTick(() => {
// 使用更大的值确保滚动到真正的底部
scrollTop.value = 99999;
// 强制触发滚动更新增加延迟确保DOM更新完成
setTimeout(() => {
scrollTop.value = scrollTop.value + Math.random();
}, 10);
const targetScrollTop = 99999 + Math.random();
scrollTop.value = targetScrollTop;
});
};
// 延时滚动
const setTimeoutScrollToBottom = () => setTimeout(() => scrollToBottom(), 100);
const setTimeoutScrollToBottom = (force = false) =>
setTimeout(() => scrollToBottom(force), 100);
// 发送普通消息
const handleReplyText = (text) => {
// 发送消息时,强制开启自动滚动
isAutoScroll.value = true;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(text);
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
};
// 是发送指令消息
const handleReplyInstruct = async (item) => {
await checkToken();
// 发送消息时,强制开启自动滚动
isAutoScroll.value = true;
messageCommonType = item.type;
// 重置消息状态准备接收新的AI回复
resetMessageState();
sendMessage(item.title, true);
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
};
// 输入区的发送消息事件
@@ -268,6 +384,8 @@ const sendMessageAction = (inputText) => {
if (!inputText.trim()) return;
handleNoHideKeyboard();
// 发送消息时,强制开启自动滚动
isAutoScroll.value = true;
// 重置消息状态准备接收新的AI回复
resetMessageState();
@@ -279,7 +397,7 @@ const sendMessageAction = (inputText) => {
}, 100);
}
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
};
/// 添加通知
@@ -296,7 +414,7 @@ const addNoticeListener = () => {
uni.$on(SCROLL_TO_BOTTOM, () => {
setTimeout(() => {
scrollToBottom();
scrollToBottom(true);
}, 200);
});
@@ -426,11 +544,14 @@ const initWebSocket = async () => {
// 连接成功后发送 welcome 消息 (messageType=4)
try {
// fire-and-forget, sendWebSocketMessage 会处理重连与队列
sendWebSocketMessage(MessageType.notice, Command.welcome, { tryReconnect: true, messageId:IdUtils.generateMessageId() }).catch((e) => {
console.warn('发送 Command.welcome 消息失败:', e);
sendWebSocketMessage(MessageType.notice, Command.messageInit, {
tryReconnect: true,
messageId: IdUtils.generateMessageId(),
}).catch((e) => {
console.warn("发送 Command.messageInit 消息失败:", e);
});
} catch (e) {
console.warn('发送 Command.welcome 消息时异常:', e);
console.warn("发送 Command.messageInit 消息时异常:", e);
}
},
@@ -488,16 +609,7 @@ const handleWebSocketMessage = (data) => {
return;
}
// Welcome 消息 (messageType=4):用于更新顶部欢迎栏内容
if (data.messageType && data.messageType === 'text') {
console.log("收到 welcome 类型消息:", data);
if (data.content) {
welcomeMessage.value = data.content;
}
return;
}
if (data.messageType && data.messageType === 'broadcast') {
if (data.messageType && data.messageType === "broadcast") {
console.log("收到 welcome 类型消息:", data);
if (data.content) {
notitceConent.value = data.content;
@@ -511,26 +623,39 @@ const handleWebSocketMessage = (data) => {
// 1) Try to find an existing AI message that already has the same replyMessageId
for (let i = chatMsgList.value.length - 1; i >= 0; i--) {
const it = chatMsgList.value[i];
if (it && it.msgType === MessageRole.AI && it.replyMessageId === data.replyMessageId) {
if (
it &&
it.msgType === MessageRole.AI &&
it.replyMessageId === data.replyMessageId
) {
aiMsgIndex = i;
break;
}
}
// 2) If not found, check pendingMap for currentSessionMessageId
if (aiMsgIndex === -1 && currentSessionMessageId && pendingMap.has(currentSessionMessageId)) {
if (
aiMsgIndex === -1 &&
currentSessionMessageId &&
pendingMap.has(currentSessionMessageId)
) {
const idx = pendingMap.get(currentSessionMessageId);
if (idx >= 0 && idx < chatMsgList.value.length) {
const item = chatMsgList.value[idx];
// If the pending item already has a different non-empty replyMessageId, create a new AI entry
if (item && item.msgType === MessageRole.AI && item.replyMessageId && item.replyMessageId !== data.replyMessageId) {
if (
item &&
item.msgType === MessageRole.AI &&
item.replyMessageId &&
item.replyMessageId !== data.replyMessageId
) {
const aiMsg = {
msgId: `msg_${chatMsgList.value.length}`,
msgType: MessageRole.AI,
msg: "",
isLoading: false,
messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || '',
replyMessageId: data.replyMessageId || "",
componentName: "",
title: "",
finish: false,
@@ -552,7 +677,7 @@ const handleWebSocketMessage = (data) => {
msg: "",
isLoading: false,
messageId: currentSessionMessageId,
replyMessageId: data.replyMessageId || '',
replyMessageId: data.replyMessageId || "",
componentName: "",
title: "",
finish: false,
@@ -563,7 +688,10 @@ const handleWebSocketMessage = (data) => {
} else {
// No replyMessageId: fall back to most recent AI message
for (let i = chatMsgList.value.length - 1; i >= 0; i--) {
if (chatMsgList.value[i] && chatMsgList.value[i].msgType === MessageRole.AI) {
if (
chatMsgList.value[i] &&
chatMsgList.value[i].msgType === MessageRole.AI
) {
aiMsgIndex = i;
break;
}
@@ -576,12 +704,12 @@ const handleWebSocketMessage = (data) => {
// 防护:确保 aiMsgIndex 有效
if (aiMsgIndex < 0 || aiMsgIndex >= chatMsgList.value.length) {
console.error('无效的 aiMsgIndex:', aiMsgIndex);
console.error("无效的 aiMsgIndex:", aiMsgIndex);
return;
}
// replyMessageId
if(data.replyMessageId) {
if (data.replyMessageId) {
chatMsgList.value[aiMsgIndex].replyMessageId = data.replyMessageId;
}
@@ -609,7 +737,9 @@ const handleWebSocketMessage = (data) => {
// 直接拼接内容到对应 AI 消息
if (data.content) {
// 如果该条消息属于 longTextCard使用 componentMsg 存储内容并保持 ChatCardAI 的 text 为空
const isLongText = aiItem.componentName === CompName.longTextCard || data.componentName === CompName.longTextCard;
const isLongText =
aiItem.componentName === CompName.longTextCard ||
data.componentName === CompName.longTextCard;
if (isLongText) {
if (aiItem.isLoading) {
aiItem.componentMsg = (aiItem.componentMsg || "") + data.content;
@@ -713,25 +843,29 @@ const sendMessage = async (message, isInstruct = false) => {
try {
await initWebSocket();
// 等待短暂时间确保连接建立
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
// 检查连接是否成功建立
if (!isWsConnected()) {
uni.hideLoading();
uni.showToast({
title: "连接服务器失败,请稍后重试",
icon: "none",
});
setTimeout(() => {
uni.showToast({
title: "连接服务器失败,请稍后重试",
icon: "none",
});
}, 100);
return;
}
uni.hideLoading();
} catch (error) {
console.error("重新连接WebSocket失败:", error);
uni.hideLoading();
uni.showToast({
title: "连接服务器失败,请稍后重试",
icon: "none",
});
setTimeout(() => {
uni.showToast({
title: "连接服务器失败,请稍后重试",
icon: "none",
});
}, 100);
return;
}
}
@@ -752,13 +886,17 @@ const sendMessage = async (message, isInstruct = false) => {
chatMsgList.value.push(newMsg);
inputMessage.value = "";
// 发送消息后滚动到底部
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
sendChat(message, isInstruct);
console.log("发送的新消息:", JSON.stringify(newMsg));
};
// 通用WebSocket消息发送函数 -> 返回 Promise<boolean>
const sendWebSocketMessage = async (messageType, messageContent, options = {}) => {
const sendWebSocketMessage = async (
messageType,
messageContent,
options = {},
) => {
const args = {
conversationId: conversationId.value,
agentId: agentId.value,
@@ -767,9 +905,11 @@ const sendWebSocketMessage = async (messageType, messageContent, options = {}) =
messageId: options.messageId || currentSessionMessageId,
};
const maxRetries = typeof options.retries === 'number' ? options.retries : 3;
const baseDelay = typeof options.baseDelay === 'number' ? options.baseDelay : 300; // ms
const maxDelay = typeof options.maxDelay === 'number' ? options.maxDelay : 5000; // ms
const maxRetries = typeof options.retries === "number" ? options.retries : 3;
const baseDelay =
typeof options.baseDelay === "number" ? options.baseDelay : 300; // ms
const maxDelay =
typeof options.maxDelay === "number" ? options.maxDelay : 5000; // ms
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// 确保连接
@@ -778,13 +918,13 @@ const sendWebSocketMessage = async (messageType, messageContent, options = {}) =
try {
await initWebSocket();
} catch (e) {
console.error('reconnect failed in sendWebSocketMessage:', e);
console.error("reconnect failed in sendWebSocketMessage:", e);
}
}
}
if (!isWsConnected()) {
if (!options.silent) console.warn('WebSocket 未连接,无法发送消息', args);
if (!options.silent) console.warn("WebSocket 未连接,无法发送消息", args);
// 如果还有重试机会,进行等待后重试
if (attempt < maxRetries) {
const delay = Math.min(maxDelay, baseDelay * Math.pow(2, attempt));
@@ -806,25 +946,41 @@ const sendWebSocketMessage = async (messageType, messageContent, options = {}) =
// 若返回 false消息可能已经被 manager 入队并触发连接流程。
// 在这种情况下避免立即当作失败处理,而是等待短暂时间以观察连接是否建立并由 manager 发送队列。
console.warn('webSocketManager.sendMessage 返回 false等待连接或队列发送...', { attempt, args });
const waitForConnectMs = typeof options.waitForConnectMs === 'number' ? options.waitForConnectMs : 5000;
if (webSocketManager && typeof webSocketManager.isConnected === 'function' && !webSocketManager.isConnected()) {
console.warn(
"webSocketManager.sendMessage 返回 false等待连接或队列发送...",
{ attempt, args },
);
const waitForConnectMs =
typeof options.waitForConnectMs === "number"
? options.waitForConnectMs
: 5000;
if (
webSocketManager &&
typeof webSocketManager.isConnected === "function" &&
!webSocketManager.isConnected()
) {
const startTs = Date.now();
while (Date.now() - startTs < waitForConnectMs) {
await sleep(200);
if (webSocketManager.isConnected()) {
// 给 manager 一点时间处理队列并发送
await sleep(150);
console.log('检测到 manager 已连接,假定队列消息已发送', args);
console.log("检测到 manager 已连接,假定队列消息已发送", args);
return true;
}
}
console.warn('等待 manager 建连超时,进入重试逻辑', { waitForConnectMs, args });
console.warn("等待 manager 建连超时,进入重试逻辑", {
waitForConnectMs,
args,
});
} else {
console.warn('sendMessage 返回 false 但 manager 看起来已连接或不可用,继续重试', { args });
console.warn(
"sendMessage 返回 false 但 manager 看起来已连接或不可用,继续重试",
{ args },
);
}
} catch (error) {
console.error('发送WebSocket消息异常:', error, args);
console.error("发送WebSocket消息异常:", error, args);
}
// 失败且还有重试机会,等待指数退避
@@ -855,8 +1011,13 @@ const sendChat = async (message, isInstruct = false) => {
isSessionActive.value = connected;
// 更新AI消息状态
const aiMsgIndex = chatMsgList.value.length - 1;
if (aiMsgIndex >= 0 && chatMsgList.value[aiMsgIndex].msgType === MessageRole.AI) {
chatMsgList.value[aiMsgIndex].msg = connected ? "" : "发送消息失败,请重试";
if (
aiMsgIndex >= 0 &&
chatMsgList.value[aiMsgIndex].msgType === MessageRole.AI
) {
chatMsgList.value[aiMsgIndex].msg = connected
? ""
: "发送消息失败,请重试";
chatMsgList.value[aiMsgIndex].isLoading = connected;
}
if (connected) {
@@ -881,14 +1042,14 @@ const sendChat = async (message, isInstruct = false) => {
msg: "思考中",
isLoading: true,
messageId: currentSessionMessageId,
replyMessageId: '',
replyMessageId: "",
componentName: "",
title: "",
finish: false,
};
chatMsgList.value.push(aiMsg);
// 添加AI消息后滚动到底部
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
const aiMsgIndex = chatMsgList.value.length - 1;
// 记录 pendingMap
@@ -897,19 +1058,26 @@ const sendChat = async (message, isInstruct = false) => {
// 启动超时回退
const timeoutId = setTimeout(() => {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx] && chatMsgList.value[idx].isLoading) {
if (
idx != null &&
chatMsgList.value[idx] &&
chatMsgList.value[idx].isLoading
) {
chatMsgList.value[idx].msg = "请求超时,请重试";
chatMsgList.value[idx].isLoading = false;
pendingMap.delete(currentSessionMessageId);
pendingTimeouts.delete(currentSessionMessageId);
isSessionActive.value = false;
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
}
}, MESSAGE_TIMEOUT);
pendingTimeouts.set(currentSessionMessageId, timeoutId);
// 发送消息,支持重连尝试
const success = await sendWebSocketMessage(messageType, messageContent, { messageId: currentSessionMessageId, tryReconnect: true });
const success = await sendWebSocketMessage(messageType, messageContent, {
messageId: currentSessionMessageId,
tryReconnect: true,
});
if (!success) {
const idx = pendingMap.get(currentSessionMessageId);
if (idx != null && chatMsgList.value[idx]) {
@@ -932,7 +1100,10 @@ const stopRequest = async () => {
// 发送中断消息给服务器 (messageType=2),带上当前 messageId
try {
await sendWebSocketMessage(MessageType.stop, "stop_request", { messageId: currentSessionMessageId, silent: true });
await sendWebSocketMessage(MessageType.stop, "stop_request", {
messageId: currentSessionMessageId,
silent: true,
});
} catch (e) {
console.warn("stopRequest send failed:", e);
}
@@ -945,12 +1116,16 @@ const stopRequest = async () => {
aiMsgIndex = chatMsgList.value.length - 1;
}
if (chatMsgList.value[aiMsgIndex] &&
chatMsgList.value[aiMsgIndex].msgType === MessageRole.AI) {
if (
chatMsgList.value[aiMsgIndex] &&
chatMsgList.value[aiMsgIndex].msgType === MessageRole.AI
) {
chatMsgList.value[aiMsgIndex].isLoading = false;
if (chatMsgList.value[aiMsgIndex].msg &&
if (
chatMsgList.value[aiMsgIndex].msg &&
chatMsgList.value[aiMsgIndex].msg.trim() &&
!chatMsgList.value[aiMsgIndex].msg.startsWith("思考中")) {
!chatMsgList.value[aiMsgIndex].msg.startsWith("思考中")
) {
// 保留已显示内容
} else {
chatMsgList.value[aiMsgIndex].msg = "请求已停止";
@@ -968,7 +1143,7 @@ const stopRequest = async () => {
// 重置会话状态
isSessionActive.value = false;
setTimeoutScrollToBottom();
setTimeoutScrollToBottom(true);
};
// 组件销毁时清理资源

View File

@@ -1,5 +1,5 @@
<template>
<view>
<view class="container">
<zero-markdown-view :markdown="text" :aiMode="true"></zero-markdown-view>
</view>
</template>
@@ -15,4 +15,13 @@ defineProps({
});
</script>
<style scoped></style>
<style scoped>
.container {
width: 100%;
}
.container ::v-deep image,
.container ::v-deep video,
.container ::v-deep iframe {
width: 100% !important;
}
</style>

View File

@@ -7,7 +7,7 @@ v
:frameHeight="spriteStyle.frameHeight" :totalFrames="spriteStyle.totalFrames" :columns="spriteStyle.columns"
:displayWidth="spriteStyle.displayWidth" :fps="16" />
<view class="welcome-text font-size-14 font-500 font-family-misans-vf color-171717 line-height-24">
{{ props.welcomeMessage || welcomeContent }}
{{ welcomeContent }}
</view>
</view>
@@ -23,10 +23,6 @@ import ChatMoreTips from "../ChatMoreTips/index.vue";
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
const props = defineProps({
welcomeMessage: {
type: String,
default: "",
},
mainPageDataModel: {
type: Object,
default: () => {

128
src/pages/webview/bridge.js Normal file
View File

@@ -0,0 +1,128 @@
import { navigateTo } from "@/router";
import { getAccessToken } from "@/constant/token";
import { updateImageFile } from "@/request/api/UpdateFile";
// 这个文件是为了在 H5 页面中使用 uni.navigateTo 方法,并且自动携带 token 参数
export const navigateToNewPage = (url, options = {}) => {
const token = getAccessToken();
navigateTo(url, { token: token }, options);
};
// 保存图片到相册的完整流程
export const saveImageToAlbum = (imageUrl) => {
if (!imageUrl) {
uni.showToast({ title: '图片地址无效', icon: 'none' });
return;
}
// 先下载图片
uni.showLoading({ title: '保存中...', mask: true });
uni.downloadFile({
url: imageUrl,
success: (res) => {
if (res.statusCode === 200) {
const tempFilePath = res.tempFilePath;
// 请求相册授权并保存
uni.getSetting({
success: (settingRes) => {
if (settingRes.authSetting['scope.writePhotosAlbum']) {
// 已授权,直接保存
saveToAlbum(tempFilePath);
} else {
// 未授权,请求授权
uni.authorize({
scope: 'scope.writePhotosAlbum',
success: () => saveToAlbum(tempFilePath),
fail: () => {
uni.hideLoading();
uni.showModal({
title: '提示',
content: '需要相册权限才能保存图片,请去设置中开启',
confirmText: '去设置',
success: (modalRes) => {
if (modalRes.confirm) {
uni.openSetting();
}
}
});
}
});
}
}
});
} else {
uni.hideLoading();
uni.showToast({ title: '图片下载失败', icon: 'none' });
}
},
fail: (err) => {
console.error('下载失败', err);
uni.hideLoading();
uni.showToast({ title: '网络错误,下载失败', icon: 'none' });
}
});
}
// 执行保存到相册
function saveToAlbum(filePath) {
uni.saveImageToPhotosAlbum({
filePath: filePath,
success: () => {
uni.hideLoading();
uni.showToast({ title: '保存成功', icon: 'success' });
},
fail: (err) => {
console.error('保存失败', err);
uni.hideLoading();
uni.showToast({ title: '保存失败', icon: 'none' });
}
});
}
/**
* 选择并上传图片
* @param {Object} options 配置项
* @param {number} options.count 最多选择的图片数量默认1
* @param {string} options.sourceType 图片来源 ['album', 'camera'],默认两者都允许
* @returns {Promise<string>} 返回上传后的图片地址res.data
*/
export function chooseAndUploadImage(options = {}) {
const { count = 1, sourceType = ['album', 'camera'] } = options
return new Promise((resolve, reject) => {
// 1. 选择图片
uni.chooseImage({
count,
sourceType,
success: (chooseRes) => {
const tempFilePaths = chooseRes.tempFilePaths
if (!tempFilePaths || tempFilePaths.length === 0) {
reject(new Error('未选择图片'))
return
}
const filePath = tempFilePaths[0] // 单张图片,取第一张
// 2. 上传图片(这里假设 updateImageFile 返回 Promiseresolve 的数据结构为 { data: '图片地址' }
updateImageFile(filePath)
.then((uploadRes) => {
const imageUrl = uploadRes.data
if (!imageUrl) {
reject(new Error('上传成功但未返回图片地址'))
return
}
resolve(imageUrl)
})
.catch((err) => {
console.error('上传失败', err)
uni.showToast({ title: '上传失败', icon: 'none' })
reject(err)
})
},
fail: (err) => {
console.error('选择图片失败', err)
uni.showToast({ title: '选择图片失败', icon: 'none' })
reject(err)
}
})
})
}

View File

@@ -1,13 +1,19 @@
<template>
<view>
<web-view :src="webviewUrl" @message="handleH5Message"></web-view>
<web-view id="webview" :src="webviewUrl" @message="handleMessage"></web-view>
</view>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
import { navigateToNewPage, saveImageToAlbum } from "./bridge.js";
const webviewUrl = ref("");
const originalWebviewUrl = ref("");
onUnmounted(() => {
uni.$off('UPLOAD_RESULT')
})
onMounted(() => {
// 获取页面参数
@@ -18,11 +24,25 @@ onMounted(() => {
// 从页面参数中获取url
if (options.url) {
// 对URL进行解码因为传递时可能被编码了
webviewUrl.value = decodeURIComponent(options.url);
const decoded = decodeURIComponent(options.url);
webviewUrl.value = decoded;
originalWebviewUrl.value = decoded;
console.log("WebView URL:", decoded);
}
chooseAndUpload();
});
const handleH5Message = (event) => {
const chooseAndUpload = () => {
uni.$on('UPLOAD_RESULT', (imageUrl) => {
console.log('收到图片地址:', imageUrl)
const resultUrl = originalWebviewUrl.value + '&imageUrl=' + encodeURIComponent(imageUrl);
console.log('新的URL:', resultUrl)
webviewUrl.value = resultUrl;
})
}
const handleMessage = (event) => {
const messageData = event.detail.data[0];
console.log("Received message from H5:", messageData);
// 根据需要处理H5传递过来的消息
@@ -33,12 +53,24 @@ const handleH5Message = (event) => {
uni.navigateBack();
break;
case "navigateTo":
navigateToNewPage(messageData.url);
break;
case "showToast":
uni.showToast({ title: messageData.title, icon: messageData.icon || "none" });
break;
case "saveImage":
saveImageToAlbum(messageData.imageUrl);
break;
case "chooseAndUpload": {
uni.navigateTo({
url: '/pages-bridge/UploadImage',
})
}
break;
break
default:
console.log("Unknown action:", action);
}
};
</script>

View File

@@ -2,6 +2,7 @@ import { getCurrentConfig } from "@/constant/base";
import { useAppStore } from "@/store";
import { NOTICE_EVENT_LOGOUT } from "@/constant/constant";
import { getAccessToken } from "@/constant/token";
import { goLogin } from "../../hooks/useGoLogin";
const clientId = getCurrentConfig().clientId;
const defaultConfig = {
@@ -60,6 +61,10 @@ function request(url, args = {}, method = "POST", customConfig = {}) {
console.log("424错误重新登录");
removeAccessToken();
uni.$emit(NOTICE_EVENT_LOGOUT);
setTimeout(() => {
/// 去登录页面
goLogin();
}, 500);
}
},
fail: (err) => {

View File

@@ -20,3 +20,4 @@
@import "./z-index.scss";
@import "./white-space.scss";
@import "./box-sizing.scss";
@import "./word-break.scss";

View File

@@ -0,0 +1,3 @@
.break-all {
word-break: break-all;
}