Im 相关修改
This commit is contained in:
parent
2cc2b01477
commit
c66514e5b3
@ -1,3 +1,4 @@
|
||||
MP_API_BASE_URL=http://localhost:8080
|
||||
MP_CACHE_PREFIX=development
|
||||
MP_WX_APP_ID=wx93af55767423938e
|
||||
MP_TIM_SDK_APP_ID=1600123876
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
MP_API_BASE_URL=http://192.168.60.2:8080
|
||||
MP_CACHE_PREFIX=development
|
||||
MP_WX_APP_ID=wx93af55767423938e
|
||||
MP_TIM_SDK_APP_ID=1600123876
|
||||
|
||||
16
.hbuilderx/launch.json
Normal file
16
.hbuilderx/launch.json
Normal file
@ -0,0 +1,16 @@
|
||||
{ // launch.json 配置了启动调试时相关设置,configurations下节点名称可为 app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
|
||||
// launchtype项可配置值为local或remote, local代表前端连本地云函数,remote代表前端连云端云函数
|
||||
"version": "0.0",
|
||||
"configurations": [{
|
||||
"default" :
|
||||
{
|
||||
"launchtype" : "local"
|
||||
},
|
||||
"mp-weixin" :
|
||||
{
|
||||
"launchtype" : "local"
|
||||
},
|
||||
"type" : "uniCloud"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -9,7 +9,9 @@
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10"
|
||||
"dayjs": "^1.11.10",
|
||||
"tim-upload-plugin": "^1.4.2",
|
||||
"tim-wx-sdk": "^2.27.6"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
@ -17,6 +19,16 @@
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
||||
},
|
||||
"node_modules/tim-upload-plugin": {
|
||||
"version": "1.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/tim-upload-plugin/-/tim-upload-plugin-1.4.3.tgz",
|
||||
"integrity": "sha512-3ZmbA36dr3eG9YGDon9MLBUtbNawYWkL+TBa+VS0Uviguc7PlVSOIVRG2C4irXX16slDT2Kj+HAZapp+Xqp2xg=="
|
||||
},
|
||||
"node_modules/tim-wx-sdk": {
|
||||
"version": "2.27.6",
|
||||
"resolved": "https://registry.npmmirror.com/tim-wx-sdk/-/tim-wx-sdk-2.27.6.tgz",
|
||||
"integrity": "sha512-zB+eRdmigdhEDeqrXC0bLJonUQZzS5uKNPLFtrje503WAnmuxVQjq/n4Zle4FYHG4FiKHKhsrVd0aCYXABlFEg=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,9 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10"
|
||||
"dayjs": "^1.11.10",
|
||||
"tim-upload-plugin": "^1.4.2",
|
||||
"tim-wx-sdk": "^2.27.6"
|
||||
},
|
||||
"uni-app": {
|
||||
"scripts": {
|
||||
@ -20,7 +22,7 @@
|
||||
"UNI_PLATFORM": "mp-weixin"
|
||||
}
|
||||
},
|
||||
"localhost": {
|
||||
"localhost": {
|
||||
"title": "本地",
|
||||
"env": {
|
||||
"UNI_PLATFORM": "mp-weixin"
|
||||
@ -29,4 +31,4 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
}
|
||||
13
pages.json
13
pages.json
@ -7,6 +7,19 @@
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/message/message",
|
||||
"style": {
|
||||
"navigationBarTitleText": "消息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/message/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "聊天",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/login/login",
|
||||
"style": {
|
||||
|
||||
210
pages/home/consult.vue
Normal file
210
pages/home/consult.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<view class="consult-container">
|
||||
<view class="consult-title">咨询互动</view>
|
||||
|
||||
<view class="consult-grid">
|
||||
<view
|
||||
class="consult-item"
|
||||
v-for="item in consultItems"
|
||||
:key="item.id"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<view class="item-icon" :style="{ backgroundColor: item.bgColor }">
|
||||
<image :src="item.icon" class="icon-img" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="item-label">{{ item.label }}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选择咨询人弹窗 -->
|
||||
<select-consultant-popup
|
||||
ref="consultantPopup"
|
||||
:customers="customers"
|
||||
:corpId="corpId"
|
||||
:teamId="teamId"
|
||||
@confirm="handleConsultantConfirm"
|
||||
@addNew="handleAddNewArchive"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import useAccount from "@/store/account";
|
||||
import api from "@/utils/api";
|
||||
import { toast } from "@/utils/widget";
|
||||
import SelectConsultantPopup from "./select-consultant-popup.vue";
|
||||
|
||||
const props = defineProps({
|
||||
corpId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
teamId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
customers: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const { account } = storeToRefs(useAccount());
|
||||
const consultantPopup = ref(null);
|
||||
|
||||
const consultItems = ref([
|
||||
{
|
||||
id: "chat",
|
||||
label: "聊天咨询",
|
||||
icon: "/static/homepage/chat-icon.png",
|
||||
bgColor: "#5DADE2",
|
||||
needSelectConsultant: true,
|
||||
},
|
||||
{
|
||||
id: "education",
|
||||
label: "我的宣教",
|
||||
icon: "/static/homepage/education-icon.png",
|
||||
bgColor: "#F4D03F",
|
||||
path: "/pages/article/article-list",
|
||||
},
|
||||
{
|
||||
id: "survey",
|
||||
label: "我的问卷",
|
||||
icon: "/static/homepage/survey-icon.png",
|
||||
bgColor: "#58D68D",
|
||||
path: "/pages/health/list",
|
||||
},
|
||||
{
|
||||
id: "rating",
|
||||
label: "服务评价",
|
||||
icon: "/static/homepage/rating-icon.png",
|
||||
bgColor: "#5DADE2",
|
||||
path: "",
|
||||
},
|
||||
]);
|
||||
|
||||
function handleItemClick(item) {
|
||||
// 聊天咨询需要选择咨询人
|
||||
if (item.needSelectConsultant) {
|
||||
if (!props.customers || props.customers.length === 0) {
|
||||
toast("请先添加档案");
|
||||
// 跳转到档案管理页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/archive/archive-manage?corpId=${props.corpId}&teamId=${props.teamId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开选择咨询人弹窗
|
||||
consultantPopup.value?.open();
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他功能直接跳转
|
||||
if (!item.path) {
|
||||
toast("功能开发中");
|
||||
return;
|
||||
}
|
||||
|
||||
uni.navigateTo({
|
||||
url: item.path,
|
||||
fail: () => {
|
||||
toast("页面跳转失败");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 确认选择咨询人
|
||||
async function handleConsultantConfirm(customer) {
|
||||
console.log("选择的咨询人:", customer);
|
||||
// 调用创建咨询群组接口
|
||||
uni.showLoading({ title: "创建咨询中..." });
|
||||
try {
|
||||
const res = await api("createConsultGroup", {
|
||||
teamId: props.teamId,
|
||||
corpId: props.corpId,
|
||||
customerId: customer._id,
|
||||
customerImUserId: account.value.openid,
|
||||
});
|
||||
uni.hideLoading();
|
||||
if (res && res.success) {
|
||||
const { groupId, isExisting } = res.data;
|
||||
// 跳转到聊天页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/message/index?conversationID=GROUP${groupId}&groupID=${groupId}`,
|
||||
});
|
||||
} else {
|
||||
toast(res?.message || "创建咨询失败");
|
||||
}
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error("创建咨询群组失败:", error);
|
||||
toast("创建咨询失败");
|
||||
}
|
||||
}
|
||||
|
||||
// 新建档案
|
||||
function handleAddNewArchive() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/archive/edit-archive?corpId=${props.corpId}&teamId=${props.teamId}`,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.consult-container {
|
||||
padding: 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
margin: 24rpx;
|
||||
}
|
||||
|
||||
.consult-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.consult-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.consult-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.consult-item:active .item-icon {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.icon-img {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.item-label {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@ -86,6 +86,8 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:customers']);
|
||||
|
||||
const { account } = storeToRefs(useAccount());
|
||||
const current = ref(null);
|
||||
const customers = ref([]);
|
||||
@ -144,6 +146,9 @@ async function getCustomers() {
|
||||
customers.value = res && Array.isArray(res.data) ? res.data : [];
|
||||
const customer = customers.value.find(i => current.value && i._id === current.value._id);
|
||||
current.value = customer || customers.value[0] || null;
|
||||
|
||||
// 向父组件传递 customers 数据
|
||||
emit('update:customers', customers.value);
|
||||
} else {
|
||||
toast(res.message || '获取档案失败');
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
<team-head :team="team" :teams="teams" @changeTeam="changeTeam" />
|
||||
<view class="pb-10"></view>
|
||||
</template>
|
||||
<customer-archive :corpId="corpId" :team="team" />
|
||||
<customer-archive :corpId="corpId" :team="team" @update:customers="handleCustomersUpdate" />
|
||||
<consult :corpId="corpId" :teamId="team.teamId" :customers="customers" />
|
||||
<team-mate :team="team" />
|
||||
<article-list :team="team" />
|
||||
</full-page>
|
||||
@ -21,6 +22,7 @@ import { toast } from '@/utils/widget';
|
||||
|
||||
import FullPage from '@/components/full-page.vue';
|
||||
import articleList from './article-list.vue';
|
||||
import consult from './consult.vue';
|
||||
import customerArchive from './customer-archive.vue';
|
||||
import teamHead from './team-head.vue';
|
||||
import teamMate from './team-mate.vue';
|
||||
@ -32,10 +34,15 @@ const { account } = storeToRefs(useAccount());
|
||||
|
||||
const team = ref(null);
|
||||
const teams = ref([]);
|
||||
const loading = ref(true)
|
||||
const loading = ref(true);
|
||||
const customers = ref([]);
|
||||
|
||||
const corpId = computed(() => team.value?.corpId);
|
||||
|
||||
function handleCustomersUpdate(newCustomers) {
|
||||
customers.value = newCustomers;
|
||||
}
|
||||
|
||||
async function changeTeam({ teamId, corpId, corpName }) {
|
||||
loading.value = true;
|
||||
const res = await api('getTeamData', { teamId, corpId });
|
||||
|
||||
316
pages/home/select-consultant-popup.vue
Normal file
316
pages/home/select-consultant-popup.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<uni-popup ref="popup" type="bottom" :safe-area="false" @change="handleChange">
|
||||
<view class="popup-container">
|
||||
<view class="popup-header">
|
||||
<view class="popup-title">选择咨询人</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="consultant-scroll">
|
||||
<view class="consultant-list">
|
||||
<!-- 咨询人卡片 -->
|
||||
<view
|
||||
v-for="customer in customers"
|
||||
:key="customer._id"
|
||||
class="consultant-card"
|
||||
:class="{ 'selected': selectedId === customer._id }"
|
||||
@click="selectCustomer(customer)"
|
||||
>
|
||||
<!-- 关系标签 -->
|
||||
<view v-if="customer.relationship" class="relationship-badge">
|
||||
{{ customer.relationship }}
|
||||
</view>
|
||||
|
||||
<view class="card-content">
|
||||
<!-- 头像 -->
|
||||
<view class="avatar-wrapper">
|
||||
<image
|
||||
v-if="customer.avatar"
|
||||
:src="customer.avatar"
|
||||
class="avatar-img"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder">
|
||||
<uni-icons type="person-filled" size="40" color="#1989fa" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<view class="user-info">
|
||||
<view class="user-name">{{ customer.name || '未命名' }}</view>
|
||||
<view class="user-detail">
|
||||
{{ customer.sex === 1 ? '男' : customer.sex === 2 ? '女' : '' }}
|
||||
{{ customer.age ? customer.age + '岁' : '' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选中标记 -->
|
||||
<view v-if="selectedId === customer._id" class="selected-mark">
|
||||
<uni-icons type="checkmarkempty" size="18" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新建档案卡片 -->
|
||||
<view class="consultant-card add-card" @click="addNewArchive">
|
||||
<view class="add-content">
|
||||
<uni-icons type="plusempty" size="32" color="#1989fa" />
|
||||
<view class="add-text">新建档案</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="popup-footer">
|
||||
<button class="confirm-btn" @click="confirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { toast } from '@/utils/widget';
|
||||
|
||||
const props = defineProps({
|
||||
customers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
corpId: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
teamId: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'addNew']);
|
||||
|
||||
const popup = ref(null);
|
||||
const selectedId = ref('');
|
||||
|
||||
function open(defaultId) {
|
||||
if (defaultId) {
|
||||
selectedId.value = defaultId;
|
||||
} else if (props.customers.length > 0) {
|
||||
selectedId.value = props.customers[0]._id;
|
||||
}
|
||||
popup.value?.open();
|
||||
}
|
||||
|
||||
function close() {
|
||||
popup.value?.close();
|
||||
}
|
||||
|
||||
function handleChange(e) {
|
||||
if (!e.show) {
|
||||
selectedId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function selectCustomer(customer) {
|
||||
selectedId.value = customer._id;
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!selectedId.value) {
|
||||
toast('请选择咨询人');
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = props.customers.find(c => c._id === selectedId.value);
|
||||
if (selected) {
|
||||
emit('confirm', selected);
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function addNewArchive() {
|
||||
close();
|
||||
emit('addNew');
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
close
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-container {
|
||||
background: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
padding: 24rpx 32rpx 20rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.consultant-scroll {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
padding: 0 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.consultant-list {
|
||||
display: inline-flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.consultant-card {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 280rpx;
|
||||
background: #f0f7ff;
|
||||
border: 2rpx solid #1989fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx;
|
||||
transition: all 0.3s;
|
||||
vertical-align: top;
|
||||
height: 80rpx;
|
||||
|
||||
&.selected {
|
||||
background: #e6f7ff;
|
||||
border-color: #1989fa;
|
||||
box-shadow: 0 4rpx 12rpx rgba(25, 137, 250, 0.2);
|
||||
}
|
||||
|
||||
&.add-card {
|
||||
border: 2rpx dashed #1989fa;
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.relationship-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 8rpx 16rpx;
|
||||
background: linear-gradient(135deg, #ffa726 0%, #ff9800 100%);
|
||||
color: #fff;
|
||||
font-size: 22rpx;
|
||||
border-radius: 0 12rpx 0 12rpx;
|
||||
font-weight: 600;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 6rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selected-mark {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #0d6efd 100%);
|
||||
border-radius: 12rpx 0 12rpx 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.add-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 26rpx;
|
||||
color: #1989fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: linear-gradient(135deg, #1989fa 0%, #0d6efd 100%);
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
border-radius: 12rpx;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(25, 137, 250, 0.3);
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
transform: translateY(2rpx);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
201
pages/message/article-detail.vue
Normal file
201
pages/message/article-detail.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<view class="article-detail-page">
|
||||
<view v-if="loading" class="loading-container">
|
||||
<uni-icons type="spinner-cycle" size="40" color="#999" />
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="error" class="error-container">
|
||||
<text class="error-text">{{ error }}</text>
|
||||
<button class="retry-btn" @click="loadArticle">重试</button>
|
||||
</view>
|
||||
|
||||
<scroll-view v-else scroll-y class="article-content">
|
||||
<view class="article-header">
|
||||
<text class="article-title">{{ articleData.title }}</text>
|
||||
<text class="article-date">{{ articleData.date }}</text>
|
||||
</view>
|
||||
<view class="article-body">
|
||||
<view class="rich-text-wrapper">
|
||||
<rich-text :nodes="articleData.content"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { getArticle } from "@/utils/api.js";
|
||||
import { ref } from "vue";
|
||||
const env = __VITE_ENV__;
|
||||
const corpId = env.MP_CORP_ID;
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const articleData = ref({
|
||||
title: "",
|
||||
content: "",
|
||||
date: "",
|
||||
});
|
||||
|
||||
let articleId = "";
|
||||
|
||||
// 处理富文本内容,使图片自适应
|
||||
const processRichTextContent = (html) => {
|
||||
if (!html) return "";
|
||||
|
||||
// 给所有 img 标签添加样式
|
||||
let processedHtml = html.replace(
|
||||
/<img/gi,
|
||||
'<img style="max-width:100%;height:auto;display:block;margin:10px 0;"'
|
||||
);
|
||||
|
||||
// 移除可能存在的固定宽度样式
|
||||
processedHtml = processedHtml.replace(
|
||||
/style="[^"]*width:\s*\d+px[^"]*"/gi,
|
||||
(match) => {
|
||||
return match.replace(/width:\s*\d+px;?/gi, "max-width:100%;");
|
||||
}
|
||||
);
|
||||
|
||||
// 处理表格,添加自适应样式
|
||||
processedHtml = processedHtml.replace(
|
||||
/<table/gi,
|
||||
'<table style="max-width:100%;overflow-x:auto;display:block;"'
|
||||
);
|
||||
|
||||
// 给整体内容添加容器样式
|
||||
processedHtml = `<div style="width:100%;overflow-x:hidden;word-wrap:break-word;word-break:break-all;">${processedHtml}</div>`;
|
||||
|
||||
return processedHtml;
|
||||
};
|
||||
|
||||
// 加载文章
|
||||
const loadArticle = async () => {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await getArticle({ id: articleId, corpId });
|
||||
|
||||
if (res.success && res.data) {
|
||||
// 格式化日期
|
||||
let date = "";
|
||||
if (res.data.createTime) {
|
||||
const d = new Date(res.data.createTime);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
date = `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
articleData.value = {
|
||||
title: res.data.title || "宣教文章",
|
||||
content: processRichTextContent(res.data.content || ""),
|
||||
date: date,
|
||||
};
|
||||
} else {
|
||||
error.value = res.message || "加载文章失败";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("加载文章失败:", err);
|
||||
error.value = "加载失败,请重试";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.id) {
|
||||
articleId = options.id;
|
||||
loadArticle();
|
||||
} else {
|
||||
error.value = "文章信息不完整";
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.article-detail-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
margin-bottom: 30rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 16rpx 60rpx;
|
||||
background-color: #0877f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.article-header {
|
||||
padding: 40rpx 30rpx 20rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
display: block;
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.article-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.rich-text-wrapper {
|
||||
padding: 30rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rich-text-wrapper ::v-deep rich-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-wrapper ::v-deep rich-text img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
588
pages/message/article-list.vue
Normal file
588
pages/message/article-list.vue
Normal file
@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<view class="article-page">
|
||||
<view class="header">
|
||||
<view class="search-bar">
|
||||
<uni-icons type="search" size="18" color="#999" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchTitle"
|
||||
placeholder="输入内容名称搜索"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content">
|
||||
<view class="category-sidebar">
|
||||
<scroll-view scroll-y class="category-scroll">
|
||||
<view
|
||||
v-for="cate in categoryList"
|
||||
:key="cate._id || 'all'"
|
||||
class="category-item"
|
||||
:class="{ active: currentCateId === cate._id }"
|
||||
@click="selectCategory(cate)"
|
||||
>
|
||||
{{ cate.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="article-list">
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="article-scroll"
|
||||
@scrolltolower="loadMore"
|
||||
lower-threshold="50"
|
||||
>
|
||||
<view
|
||||
v-if="loading && articleList.length === 0"
|
||||
class="loading-container"
|
||||
>
|
||||
<uni-icons type="spinner-cycle" size="30" color="#999" />
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="articleList.length === 0" class="empty-container">
|
||||
<empty-data title="暂无文章" />
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<view
|
||||
v-for="article in articleList"
|
||||
:key="article._id"
|
||||
class="article-item"
|
||||
>
|
||||
<view class="article-content" @click="previewArticle(article)">
|
||||
<text class="article-title">{{ article.title }}</text>
|
||||
<view class="article-footer">
|
||||
<text class="article-date">{{ article.date }}</text>
|
||||
<button
|
||||
class="send-btn"
|
||||
size="mini"
|
||||
type="primary"
|
||||
@click.stop="sendArticle(article)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && articleList.length > 0" class="loading-more">
|
||||
<uni-icons type="spinner-cycle" size="20" color="#999" />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="!loading && articleList.length >= total"
|
||||
class="no-more"
|
||||
>
|
||||
没有更多了
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 文章预览弹窗 -->
|
||||
<uni-popup ref="previewPopup" type="bottom" :safe-area="false">
|
||||
<view class="preview-container">
|
||||
<view class="preview-header">
|
||||
<text class="preview-title">{{ previewArticleData.title }}</text>
|
||||
<view class="preview-close" @click="closePreview">
|
||||
<uni-icons type="closeempty" size="24" color="#333" />
|
||||
</view>
|
||||
</view>
|
||||
<scroll-view scroll-y class="preview-content">
|
||||
<view class="rich-text-wrapper">
|
||||
<rich-text :nodes="previewArticleData.content"></rich-text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="preview-footer">
|
||||
<button class="preview-close-btn" @click="closePreview">关闭</button>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import {
|
||||
getArticleCateList,
|
||||
getArticleList,
|
||||
getArticle,
|
||||
sendArticleMessage,
|
||||
} from "@/utils/api.js";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import EmptyData from "@/components/empty-data.vue";
|
||||
|
||||
const accountStore = useAccountStore();
|
||||
const env = __VITE_ENV__;
|
||||
const corpId = env.MP_CORP_ID;
|
||||
|
||||
// 从页面参数获取群组信息
|
||||
const pageParams = ref({
|
||||
groupId: "",
|
||||
userId: "",
|
||||
corpId: "",
|
||||
});
|
||||
|
||||
// 搜索关键词
|
||||
const searchTitle = ref("");
|
||||
let searchTimer = null;
|
||||
|
||||
// 分类列表
|
||||
const categoryList = ref([{ _id: "", label: "全部" }]);
|
||||
const currentCateId = ref(""); // 默认选中"全部"(_id 为空字符串)
|
||||
|
||||
// 文章列表
|
||||
const articleList = ref([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const total = ref(0);
|
||||
|
||||
// 预览文章数据
|
||||
const previewArticleData = ref({
|
||||
title: "",
|
||||
content: "",
|
||||
});
|
||||
const previewPopup = ref(null);
|
||||
|
||||
// 获取分类列表
|
||||
const getCategoryList = async () => {
|
||||
try {
|
||||
const res = await getArticleCateList({ corpId: corpId });
|
||||
if (res.success && res.list) {
|
||||
const cates = res.list || [];
|
||||
categoryList.value = [{ _id: "", label: "全部" }, ...cates];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取分类列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择分类
|
||||
const selectCategory = (cate) => {
|
||||
currentCateId.value = cate._id || "";
|
||||
page.value = 1;
|
||||
articleList.value = [];
|
||||
loadArticleList();
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1;
|
||||
articleList.value = [];
|
||||
loadArticleList();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 加载文章列表
|
||||
const loadArticleList = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
corpId: corpId,
|
||||
page: page.value,
|
||||
pageSize: pageSize,
|
||||
enable: true,
|
||||
title: searchTitle.value,
|
||||
};
|
||||
|
||||
// 如果选择了分类,添加分类ID
|
||||
if (currentCateId.value) {
|
||||
params.cateIds = [currentCateId.value];
|
||||
}
|
||||
|
||||
const res = await getArticleList(params);
|
||||
if (res.success && res.list) {
|
||||
const { list = [], total: count = 0 } = res;
|
||||
const formattedList = list.map((item) => {
|
||||
// 格式化日期
|
||||
let date = "";
|
||||
if (item.createTime) {
|
||||
const d = new Date(item.createTime);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
date = `${year}-${month}-${day}`;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
date,
|
||||
};
|
||||
});
|
||||
|
||||
if (page.value === 1) {
|
||||
articleList.value = formattedList;
|
||||
} else {
|
||||
articleList.value = [...articleList.value, ...formattedList];
|
||||
}
|
||||
total.value = count;
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || "获取文章列表失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载文章列表失败:", error);
|
||||
uni.showToast({
|
||||
title: "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
if (loading.value || articleList.value.length >= total.value) return;
|
||||
page.value += 1;
|
||||
loadArticleList();
|
||||
};
|
||||
|
||||
// 处理富文本内容,使图片自适应
|
||||
const processRichTextContent = (html) => {
|
||||
if (!html) return "";
|
||||
|
||||
// 给所有 img 标签添加样式
|
||||
let processedHtml = html.replace(
|
||||
/<img/gi,
|
||||
'<img style="max-width:100%;height:auto;display:block;margin:10px 0;"'
|
||||
);
|
||||
|
||||
// 移除可能存在的固定宽度样式
|
||||
processedHtml = processedHtml.replace(
|
||||
/style="[^"]*width:\s*\d+px[^"]*"/gi,
|
||||
(match) => {
|
||||
return match.replace(/width:\s*\d+px;?/gi, "max-width:100%;");
|
||||
}
|
||||
);
|
||||
|
||||
// 处理表格,添加自适应样式
|
||||
processedHtml = processedHtml.replace(
|
||||
/<table/gi,
|
||||
'<table style="max-width:100%;overflow-x:auto;display:block;"'
|
||||
);
|
||||
|
||||
// 给整体内容添加容器样式
|
||||
processedHtml = `<div style="width:100%;overflow-x:hidden;word-wrap:break-word;word-break:break-all;">${processedHtml}</div>`;
|
||||
|
||||
return processedHtml;
|
||||
};
|
||||
|
||||
// 预览文章
|
||||
const previewArticle = async (article) => {
|
||||
try {
|
||||
uni.showLoading({ title: "加载中..." });
|
||||
const res = await getArticle({ id: article._id, corpId: corpId });
|
||||
uni.hideLoading();
|
||||
|
||||
if (res.success && res.data) {
|
||||
previewArticleData.value = {
|
||||
title: res.data.title || article.title,
|
||||
content: processRichTextContent(res.data.content || ""),
|
||||
};
|
||||
previewPopup.value?.open();
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || "预览文章失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
uni.hideLoading();
|
||||
console.error("预览文章失败:", error);
|
||||
uni.showToast({
|
||||
title: "预览失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭预览
|
||||
const closePreview = () => {
|
||||
previewPopup.value?.close();
|
||||
};
|
||||
|
||||
// 发送文章
|
||||
const sendArticle = async (article) => {
|
||||
try {
|
||||
const { doctorInfo } = useAccountStore();
|
||||
const result = await sendArticleMessage({
|
||||
groupId: pageParams.value.groupId,
|
||||
fromAccount: doctorInfo.weChatOpenId,
|
||||
articleId: article._id,
|
||||
title: article.title || "宣教文章",
|
||||
imgUrl: article.cover || "",
|
||||
desc: "点击查看详情",
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
throw new Error(result.message || "发送失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("发送文章失败:", error);
|
||||
uni.showToast({
|
||||
title: error.message || "发送失败",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
// 页面加载时接收参数
|
||||
onLoad((options) => {
|
||||
if (options.groupId) {
|
||||
pageParams.value.groupId = options.groupId;
|
||||
}
|
||||
if (options.userId) {
|
||||
pageParams.value.userId = options.userId;
|
||||
}
|
||||
if (options.corpId) {
|
||||
pageParams.value.corpId = options.corpId;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
getCategoryList();
|
||||
loadArticleList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.article-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
margin-left: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-sidebar {
|
||||
width: 200rpx;
|
||||
background-color: #f8f8f8;
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.category-item {
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background-color: #fff;
|
||||
color: #0877f1;
|
||||
font-weight: bold;
|
||||
border-left: 4rpx solid #0877f1;
|
||||
}
|
||||
|
||||
.article-list {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.article-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
padding: 24rpx 30rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.article-item:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.article-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
word-break: break-all;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
padding: 8rpx 32rpx;
|
||||
height: auto;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #fff;
|
||||
padding: 20rpx;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
/* 预览弹窗样式 */
|
||||
.preview-container {
|
||||
background-color: #fff;
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
flex: 1;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.preview-close {
|
||||
padding: 10rpx;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rich-text-wrapper {
|
||||
padding: 30rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* rich-text 内部样式 */
|
||||
.rich-text-wrapper ::v-deep rich-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rich-text-wrapper ::v-deep rich-text img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
padding: 20rpx;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.preview-close-btn {
|
||||
width: 100%;
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
1360
pages/message/chat.scss
Normal file
1360
pages/message/chat.scss
Normal file
File diff suppressed because it is too large
Load Diff
1058
pages/message/common-phrases.vue
Normal file
1058
pages/message/common-phrases.vue
Normal file
File diff suppressed because it is too large
Load Diff
454
pages/message/components/chat-input.vue
Normal file
454
pages/message/components/chat-input.vue
Normal file
@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<view class="input-section">
|
||||
<view class="input-toolbar">
|
||||
<view @click="toggleVoiceInput" class="voice-toggle-btn">
|
||||
<uni-icons v-if="showVoiceInput" fontFamily="keyboard" :size="28">{{ '' }}</uni-icons>
|
||||
<uni-icons v-else type="mic" size="28" color="#666" />
|
||||
</view>
|
||||
<view class="input-area">
|
||||
<input v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
|
||||
@confirm="sendTextMessage" @focus="handleInputFocus" />
|
||||
<input v-else class="voice-input-btn" :class="{ recording: isRecording }" @touchstart="startRecord"
|
||||
@touchmove="onRecordTouchMove" @touchend="stopRecord" @touchcancel="cancelRecord" :placeholder="isRecording ? '松开发送' : '按住说话'" disabled>
|
||||
</input>
|
||||
</view>
|
||||
<button v-if="inputText.trim()" class="send-btn" @click="sendTextMessage">
|
||||
发送
|
||||
</button>
|
||||
<view v-else class="plus-btn" @click="toggleMorePanel()">
|
||||
<uni-icons type="plusempty" size="28" color="#666" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="more-panel" v-if="showMorePanel">
|
||||
<view v-for="btn in morePanelButtons" :key="btn.text" class="more-btn" @click="btn.action">
|
||||
<image :src="btn.icon" class="more-icon" mode="aspectFit"></image>
|
||||
<text>{{ btn.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音遮罩层 -->
|
||||
<view v-if="isRecording" class="recording-overlay">
|
||||
<view class="recording-modal" :class="{ 'cancel-mode': isCancelMode }">
|
||||
<view class="recording-icon-container">
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-1"></view>
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-2"></view>
|
||||
<view v-if="!isCancelMode" class="wave-circle wave-3"></view>
|
||||
<view class="mic-icon-wrapper" :class="{ 'cancel-icon': isCancelMode }">
|
||||
<uni-icons v-if="!isCancelMode" type="mic-filled" size="60" color="#fff" />
|
||||
<uni-icons v-else type="closeempty" size="60" color="#fff" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="recording-text" :class="{ 'cancel-text': isCancelMode }">
|
||||
{{ isCancelMode ? '松开手指,取消录音' : '正在录音...' }}
|
||||
</view>
|
||||
<view v-if="!isCancelMode" class="recording-hint">松开发送,上滑取消</view>
|
||||
<view v-if="!isCancelMode" class="recording-duration">{{ recordingDuration }}s</view>
|
||||
<view v-else class="cancel-hint">
|
||||
<uni-icons type="up" size="20" color="#ff4757" />
|
||||
<text>已上滑</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import {
|
||||
chooseImage,
|
||||
takePhoto as takePhotoUtil,
|
||||
initRecorderManager as initRecorderManagerUtil,
|
||||
startRecord as startRecordUtil,
|
||||
stopRecord as stopRecordUtil,
|
||||
createCustomMessage,
|
||||
sendCustomMessage as sendCustomMessageUtil,
|
||||
sendMessage as sendMessageUtil,
|
||||
checkRecordingDuration,
|
||||
validateBeforeSend,
|
||||
} from "@/utils/chat-utils.js";
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
timChatManager: { type: Object, required: true },
|
||||
patientInfo: { type: Object, default: () => ({}) },
|
||||
chatRoomBusiness: { type: Object, default: () => ({}) },
|
||||
formatTime: { type: Function, required: true },
|
||||
groupId: { type: String, default: '' },
|
||||
userId: { type: String, default: '' },
|
||||
corpId: { type: String, default: '' },
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(["messageSent", "scrollToBottom", "endConsult"]);
|
||||
|
||||
// 输入相关状态
|
||||
const inputText = ref("");
|
||||
const showVoiceInput = ref(false);
|
||||
const showMorePanel = ref(false);
|
||||
const isRecording = ref(false);
|
||||
const recordingText = ref("录音中...");
|
||||
const cloudCustomData = computed(() => {
|
||||
const arr = [
|
||||
props.chatRoomBusiness.businessType,
|
||||
props.chatRoomBusiness.businessId,
|
||||
];
|
||||
return arr.filter(Boolean).join("|");
|
||||
});
|
||||
|
||||
// 录音相关扩展状态(特效 + 取消逻辑)
|
||||
const recordingDuration = ref(0);
|
||||
let recordingTimer = null;
|
||||
const isCancelMode = ref(false);
|
||||
let touchStartY = 0;
|
||||
const CANCEL_DISTANCE = 100;
|
||||
let discardRecording = false; // 取消后丢弃本次录音
|
||||
|
||||
// 录音管理器
|
||||
let recorderManager = null;
|
||||
|
||||
// 初始化录音管理器
|
||||
const initRecorderManager = () => {
|
||||
recorderManager = initRecorderManagerUtil(
|
||||
{},
|
||||
(res) => {
|
||||
// 若本次被标记为丢弃(取消录音触发的 stop),则忽略发送
|
||||
if (discardRecording) {
|
||||
discardRecording = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查录音时长
|
||||
if (
|
||||
!checkRecordingDuration(res, () => {
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置录音状态
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
|
||||
// 发送语音消息
|
||||
const duration = Math.floor(res.duration / 1000);
|
||||
sendVoiceMessage(res, duration);
|
||||
},
|
||||
(err) => {
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
discardRecording = false;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 发送文本消息
|
||||
const sendTextMessage = async () => {
|
||||
if (!inputText.value.trim()) return;
|
||||
|
||||
await sendMessage("text", inputText.value);
|
||||
inputText.value = "";
|
||||
};
|
||||
|
||||
// 从常用语发送文本消息
|
||||
const sendTextMessageFromPhrase = async (content) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
await sendMessage("text", content);
|
||||
|
||||
// 发送成功后滚动到底部
|
||||
nextTick(() => {
|
||||
emit("scrollToBottom");
|
||||
});
|
||||
};
|
||||
|
||||
// 暴露方法给父组件调用
|
||||
defineExpose({
|
||||
sendTextMessageFromPhrase
|
||||
});
|
||||
|
||||
// 发送图片消息
|
||||
const sendImageMessage = async (imageFile) => {
|
||||
console.log("chat-input sendImageMessage 被调用,参数:", imageFile);
|
||||
await sendMessage("image", imageFile);
|
||||
};
|
||||
|
||||
// 发送语音消息
|
||||
const sendVoiceMessage = async (voiceFile, duration) => {
|
||||
await sendMessage("voice", { file: voiceFile, duration });
|
||||
};
|
||||
|
||||
// 发送消息的通用方法(文本、图片、语音)
|
||||
const sendMessage = async (messageType, data) => {
|
||||
await sendMessageUtil(
|
||||
messageType,
|
||||
data,
|
||||
props.timChatManager,
|
||||
() => validateBeforeSend(false, false, props.timChatManager),
|
||||
() => {
|
||||
showMorePanel.value = false;
|
||||
// 发送成功后滚动到底部
|
||||
emit("messageSent");
|
||||
},
|
||||
cloudCustomData.value
|
||||
);
|
||||
};
|
||||
|
||||
// 发送自定义消息的通用方法
|
||||
const sendCustomMessage = async (messageData) => {
|
||||
await sendCustomMessageUtil(
|
||||
messageData,
|
||||
props.timChatManager,
|
||||
() => validateBeforeSend(false, false, props.timChatManager),
|
||||
() => {
|
||||
showMorePanel.value = false;
|
||||
emit("messageSent");
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
// 输入相关方法
|
||||
const toggleVoiceInput = () => {
|
||||
showVoiceInput.value = !showVoiceInput.value;
|
||||
showMorePanel.value = false;
|
||||
};
|
||||
|
||||
const toggleMorePanel = () => {
|
||||
showMorePanel.value = !showMorePanel.value;
|
||||
showVoiceInput.value = false;
|
||||
};
|
||||
|
||||
// 处理图片选择
|
||||
const showImagePicker = () => {
|
||||
chooseImage(
|
||||
(file) => {
|
||||
console.log("选择图片成功,文件对象:", file);
|
||||
// 直接传递文件对象,不需要额外处理
|
||||
sendImageMessage(file);
|
||||
},
|
||||
(err) => {
|
||||
console.error("选择图片失败:", err);
|
||||
if (
|
||||
!err.errMsg?.includes("permission") &&
|
||||
!err.errMsg?.includes("auth") &&
|
||||
!err.errMsg?.includes("拒绝") &&
|
||||
!err.errMsg?.includes("未授权")
|
||||
) {
|
||||
uni.showToast({
|
||||
title: "选择图片失败,请重试",
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const takePhoto = () => {
|
||||
takePhotoUtil(
|
||||
(file) => {
|
||||
console.log("拍照成功,文件对象:", file);
|
||||
// 直接传递文件对象,不需要额外处理
|
||||
sendImageMessage(file);
|
||||
},
|
||||
(err) => {
|
||||
console.error("拍照失败:", err);
|
||||
if (
|
||||
!err.errMsg?.includes("permission") &&
|
||||
!err.errMsg?.includes("auth") &&
|
||||
!err.errMsg?.includes("拒绝") &&
|
||||
!err.errMsg?.includes("未授权")
|
||||
) {
|
||||
uni.showToast({
|
||||
title: "拍照失败,请重试",
|
||||
icon: "none",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function startDurationTimer() {
|
||||
clearDurationTimer();
|
||||
recordingDuration.value = 0;
|
||||
recordingTimer = setInterval(() => {
|
||||
recordingDuration.value++;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearDurationTimer() {
|
||||
if (recordingTimer) {
|
||||
clearInterval(recordingTimer);
|
||||
recordingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const startRecord = (e) => {
|
||||
isRecording.value = true;
|
||||
recordingText.value = "录音中...";
|
||||
isCancelMode.value = false;
|
||||
discardRecording = false;
|
||||
recordingDuration.value = 0;
|
||||
if (e && e.touches && e.touches[0]) {
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
|
||||
// 确保录音管理器已初始化
|
||||
if (!recorderManager) {
|
||||
initRecorderManager();
|
||||
}
|
||||
|
||||
startDurationTimer();
|
||||
startRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
const onRecordTouchMove = (e) => {
|
||||
if (!isRecording.value) return;
|
||||
if (e && e.touches && e.touches[0]) {
|
||||
const currentY = e.touches[0].clientY;
|
||||
const deltaY = touchStartY - currentY; // 上滑为正
|
||||
isCancelMode.value = deltaY > CANCEL_DISTANCE;
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecord = () => {
|
||||
if (!isRecording.value) return;
|
||||
// 如果处于取消模式,则按取消处理
|
||||
if (isCancelMode.value) {
|
||||
cancelRecord();
|
||||
return;
|
||||
}
|
||||
isRecording.value = false;
|
||||
recordingText.value = "录音中...";
|
||||
clearDurationTimer();
|
||||
stopRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
const cancelRecord = () => {
|
||||
if (!isRecording.value) return;
|
||||
isRecording.value = false;
|
||||
isCancelMode.value = false;
|
||||
recordingText.value = "已取消";
|
||||
discardRecording = true; // 标记为丢弃,阻止 onStop 发送
|
||||
clearDurationTimer();
|
||||
stopRecordUtil(recorderManager);
|
||||
};
|
||||
|
||||
// 发送问卷调查消息
|
||||
const sendSurveyMessage = async () => {
|
||||
const surveyMessage = createCustomMessage(
|
||||
"survey",
|
||||
{
|
||||
content: "医生发送了问卷调查",
|
||||
surveyTitle: "治疗效果评估",
|
||||
surveyDescription: "您好,为了帮助了解您的病情变化,请您如实填写问卷。",
|
||||
surveyMessage: "慢性病患者生活质量评估问卷",
|
||||
estimatedTime: "约3-5分钟",
|
||||
reward: "积分奖励10分",
|
||||
note: "问卷内容涉及您的症状变化、用药情况等,请根据实际情况填写。",
|
||||
},
|
||||
props.formatTime
|
||||
);
|
||||
|
||||
await sendCustomMessage(surveyMessage);
|
||||
};
|
||||
|
||||
// 跳转到常用语页面
|
||||
const goToCommonPhrases = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/message/common-phrases'
|
||||
});
|
||||
};
|
||||
|
||||
// 跳转到宣教文章页面
|
||||
const goToArticleList = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/message/article-list?groupId=${props.groupId}&userId=${props.userId}&corpId=${props.corpId}`
|
||||
});
|
||||
};
|
||||
|
||||
// 跳转到问卷列表页面
|
||||
const goToSurveyList = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/message/survey-list'
|
||||
});
|
||||
};
|
||||
|
||||
// 结束问诊
|
||||
const handleEndConsult = () => {
|
||||
uni.showModal({
|
||||
title: '确认结束问诊',
|
||||
content: '确定要结束本次问诊吗?结束后将无法继续对话。',
|
||||
confirmText: '确定结束',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 关闭功能面板
|
||||
showMorePanel.value = false;
|
||||
// 触发父组件的结束问诊事件
|
||||
emit('endConsult');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const morePanelButtons = [
|
||||
{ text: "照片", icon: "/static/icon/zhaopian.png", action: showImagePicker },
|
||||
{
|
||||
text: "回访任务",
|
||||
icon: "/static/icon/zhaopian.png",
|
||||
action: showImagePicker,
|
||||
},
|
||||
{
|
||||
text: "常用语",
|
||||
icon: "/static/icon/changyongyu.png",
|
||||
action: goToCommonPhrases,
|
||||
},
|
||||
{
|
||||
text: "宣教",
|
||||
icon: "/static/icon/xuanjiaowenzhang.png",
|
||||
action: goToArticleList,
|
||||
},
|
||||
{
|
||||
text: "问卷",
|
||||
icon: "/static/icon/wenjuan.png",
|
||||
action: goToSurveyList,
|
||||
},
|
||||
{
|
||||
text: "结束问诊",
|
||||
icon: "/static/icon/jieshuzixun.png",
|
||||
action: handleEndConsult,
|
||||
},
|
||||
];
|
||||
|
||||
function handleInputFocus() {
|
||||
console.log("handleInputFocus");
|
||||
nextTick().then(() => {
|
||||
emit("scrollToBottom");
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 初始化录音管理器
|
||||
initRecorderManager();
|
||||
|
||||
// 监听关闭功能栏事件
|
||||
uni.$on("closeMorePanel", () => {
|
||||
showMorePanel.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听
|
||||
uni.$off("closeMorePanel");
|
||||
clearDurationTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
88
pages/message/components/consult-accept.vue
Normal file
88
pages/message/components/consult-accept.vue
Normal file
@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="consult-accept-container">
|
||||
<view class="accept-card">
|
||||
<view class="accept-content">
|
||||
<text class="accept-text">患者已发起咨询申请,请及时接诊</text>
|
||||
</view>
|
||||
<view class="accept-actions">
|
||||
<button class="btn-cancel" @click="handleReject">暂不接受</button>
|
||||
<button class="btn-confirm" @click="handleAccept">接受咨询</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
const emit = defineEmits(['accept', 'reject']);
|
||||
|
||||
const handleAccept = () => {
|
||||
emit('accept');
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
emit('reject');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.consult-accept-container {
|
||||
width: 100%;
|
||||
padding: 20rpx 32rpx;
|
||||
background-color: #f5f5f5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.accept-card {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.accept-content {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.accept-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.accept-actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-confirm {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-cancel::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-confirm::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
70
pages/message/components/consult-cancel.vue
Normal file
70
pages/message/components/consult-cancel.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="consult-cancel-container">
|
||||
<view class="cancel-card">
|
||||
<view class="cancel-content">
|
||||
<text class="cancel-text">您的咨询申请已发送,等待医生接诊</text>
|
||||
</view>
|
||||
<view class="cancel-actions">
|
||||
<button class="btn-cancel" @click="handleCancel">取消申请</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits } from 'vue';
|
||||
|
||||
const emit = defineEmits(['cancel']);
|
||||
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.consult-cancel-container {
|
||||
width: 100%;
|
||||
padding: 20rpx 32rpx;
|
||||
background-color: #f5f5f5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cancel-card {
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 32rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.cancel-content {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cancel-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-cancel::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
59
pages/message/components/head-card.vue
Normal file
59
pages/message/components/head-card.vue
Normal file
@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<view>
|
||||
<!-- 等待医生接诊卡片 -->
|
||||
<view class="waiting-section">
|
||||
<view class="waiting-bg"></view>
|
||||
<view class="waiting-card-outer">
|
||||
<view class="hospital-name">{{ order.hospitalName || '医院' }}</view>
|
||||
<view class="waiting-consultation-card">
|
||||
<view class="waiting-card-row">
|
||||
<image class="waiting-illust" src="/static/home/waiting-illust.png" mode="aspectFit"></image>
|
||||
<view class="waiting-title-row">
|
||||
<text class="waiting-title">等待医生接诊.....</text>
|
||||
</view>
|
||||
<view class="doctor-avatar-outer">
|
||||
<image class="doctor-avatar" :src="avatar" mode="aspectFill">
|
||||
</image>
|
||||
</view>
|
||||
</view>
|
||||
<view class="waiting-desc">为了更好的获得医生帮助,请补充病情描述</view>
|
||||
<view class="waiting-btn-wrap">
|
||||
<button class="waiting-btn" @click="addSymptomDescription">
|
||||
{{ hasFilledDescription ? '已提交病情描述' : '补充病情描述' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const emit = defineEmits(['addSymptomDescription']);
|
||||
const props = defineProps({
|
||||
order: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
doctorInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const hasFilledDescription = computed(() => props.order && ('description' in props.order));
|
||||
const avatar = computed(() => props.doctorInfo?.avatar || '/static/home/avatar.svg')
|
||||
|
||||
function addSymptomDescription() {
|
||||
if (!hasFilledDescription.value) {
|
||||
emit('addSymptomDescription')
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
282
pages/message/components/message-types.vue
Normal file
282
pages/message/components/message-types.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<!-- 文本消息 -->
|
||||
<text v-if="message.type === 'TIMTextElem'" class="message-text">
|
||||
{{ message.payload.text }}
|
||||
</text>
|
||||
|
||||
<!-- 图片消息 -->
|
||||
<image
|
||||
v-else-if="message.type === 'TIMImageElem'"
|
||||
class="message-image"
|
||||
:src="
|
||||
message.payload.imageInfoArray[0].LocalURL ||
|
||||
message.payload.imageInfoArray[0].url
|
||||
"
|
||||
mode="aspectFill"
|
||||
:style="getImageStyle(message.payload.imageInfoArray[0])"
|
||||
@click="
|
||||
$emit(
|
||||
'previewImage',
|
||||
message.payload.imageInfoArray[0].LocalURL ||
|
||||
message.payload.imageInfoArray[0].url
|
||||
)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 语音消息 -->
|
||||
<view
|
||||
v-else-if="message.type === 'TIMSoundElem'"
|
||||
class="voice-message"
|
||||
:class="{ 'voice-playing': isPlaying }"
|
||||
:style="getVoiceStyle(message.payload.second)"
|
||||
@click="$emit('playVoice', message)"
|
||||
>
|
||||
<view class="voice-content">
|
||||
<view class="voice-icon-wrapper">
|
||||
<uni-icons
|
||||
type="sound"
|
||||
size="20"
|
||||
:color="message.flow === 'out' ? '#fff' : '#333'"
|
||||
:class="{ 'icon-animate': isPlaying }"
|
||||
/>
|
||||
<!-- 播放中的声波动画 -->
|
||||
<view v-if="isPlaying" class="sound-wave">
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0s"
|
||||
></view>
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0.2s"
|
||||
></view>
|
||||
<view
|
||||
class="wave-bar"
|
||||
:style="{ background: message.flow === 'out' ? '#fff' : '#0877f1' }"
|
||||
style="animation-delay: 0.4s"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="voice-duration">{{ message.payload.second }}"</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义消息卡片 -->
|
||||
<template v-else-if="message.type === 'TIMCustomElem'">
|
||||
<!-- 文章消息 -->
|
||||
<view
|
||||
v-if="getCustomMessageType(message) === 'article'"
|
||||
class="article-card"
|
||||
@click="handleArticleClick(message)"
|
||||
>
|
||||
<view class="article-content">
|
||||
<view class="article-title">{{ getArticleData(message).title }}</view>
|
||||
<view class="article-desc">{{ getArticleData(message).desc }}</view>
|
||||
</view>
|
||||
<image
|
||||
v-if="getArticleData(message).imgUrl"
|
||||
class="article-image"
|
||||
:src="getArticleData(message).imgUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 问卷消息 -->
|
||||
<view
|
||||
v-else-if="getCustomMessageType(message) === 'survey'"
|
||||
class="survey-card"
|
||||
@click="handleSurveyClick(message)"
|
||||
>
|
||||
<view class="survey-content">
|
||||
<view class="survey-title">{{ getSurveyData(message).title }}</view>
|
||||
<view class="survey-desc">{{ getSurveyData(message).desc }}</view>
|
||||
</view>
|
||||
<image
|
||||
v-if="getSurveyData(message).imgUrl"
|
||||
class="survey-image"
|
||||
:src="getSurveyData(message).imgUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 其他自定义消息 -->
|
||||
<!-- <view
|
||||
v-else
|
||||
class="card-avatar-row"
|
||||
@click="() => console.log('点击头像', message)"
|
||||
>
|
||||
<MessageCard
|
||||
:payload="message.payload"
|
||||
:messageData="getParsedCustomMessage(message, formatTime)"
|
||||
:flow="message.flow"
|
||||
@viewDetail="$emit('viewDetail', $event)"
|
||||
/>
|
||||
</view> -->
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { getParsedCustomMessage } from "@/utils/chat-utils.js";
|
||||
// import MessageCard from "./message-card/message-card.vue";
|
||||
|
||||
const props = defineProps({
|
||||
message: Object,
|
||||
patientInfo: Object,
|
||||
formatTime: Function,
|
||||
playingVoiceId: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(["playVoice", "previewImage", "viewDetail"]);
|
||||
|
||||
// 计算当前语音是否正在播放
|
||||
const isPlaying = computed(() => {
|
||||
return props.playingVoiceId === props.message.ID;
|
||||
});
|
||||
|
||||
// 计算图片样式
|
||||
const getImageStyle = (imageInfo) => {
|
||||
// 如果没有尺寸信息,使用默认样式
|
||||
imageInfo.width = imageInfo.width || imageInfo.Width;
|
||||
imageInfo.height = imageInfo.height || imageInfo.Height;
|
||||
if (!imageInfo || !imageInfo.width || !imageInfo.height) {
|
||||
return {
|
||||
width: "400rpx",
|
||||
height: "300rpx",
|
||||
};
|
||||
}
|
||||
|
||||
const maxWidth = 400; // 最大宽度400rpx
|
||||
const maxHeight = 400; // 最大高度400rpx
|
||||
const minHeight = 120; // 最小高度120rpx
|
||||
|
||||
let width = imageInfo.width;
|
||||
let height = imageInfo.height;
|
||||
|
||||
// 如果图片尺寸超过限制,按比例缩放
|
||||
if (width > maxWidth || height > maxHeight) {
|
||||
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||
width = Math.floor(width * ratio);
|
||||
height = Math.floor(height * ratio);
|
||||
}
|
||||
|
||||
// 确保最小高度
|
||||
if (height < minHeight) {
|
||||
height = minHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
width: width + "rpx",
|
||||
height: height + "rpx",
|
||||
};
|
||||
};
|
||||
|
||||
// 计算语音消息宽度
|
||||
const getVoiceStyle = (duration) => {
|
||||
// 语音时长,单位:秒
|
||||
const second = Number(duration) || 1;
|
||||
|
||||
// 基础宽度:100rpx(包含图标和时长文字的最小宽度)
|
||||
const baseWidth = 100;
|
||||
|
||||
// 最小宽度:100rpx,最大宽度:400rpx
|
||||
const minWidth = 100;
|
||||
const maxWidth = 400;
|
||||
|
||||
// 每秒增加的宽度:15rpx
|
||||
// 1秒:100rpx,2秒:115rpx,3秒:130rpx ... 60秒:400rpx
|
||||
const widthPerSecond = 15;
|
||||
|
||||
// 计算宽度
|
||||
let width = baseWidth + (second - 1) * widthPerSecond;
|
||||
|
||||
// 限制在最小和最大宽度之间
|
||||
width = Math.max(minWidth, Math.min(width, maxWidth));
|
||||
|
||||
return {
|
||||
width: width + "rpx",
|
||||
};
|
||||
};
|
||||
|
||||
// 获取自定义消息类型
|
||||
const getCustomMessageType = (message) => {
|
||||
try {
|
||||
if (message.payload && message.payload.data) {
|
||||
const data = JSON.parse(message.payload.data);
|
||||
return data.type || "";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析自定义消息失败:", error);
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// 获取文章数据
|
||||
const getArticleData = (message) => {
|
||||
try {
|
||||
if (message.payload && message.payload.data) {
|
||||
const data = JSON.parse(message.payload.data);
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析文章数据失败:", error);
|
||||
}
|
||||
return {
|
||||
title: "宣教文章",
|
||||
desc: "宣教文章",
|
||||
url: "",
|
||||
imgUrl: "",
|
||||
};
|
||||
};
|
||||
|
||||
// 处理文章点击
|
||||
const handleArticleClick = (message) => {
|
||||
const { articleId } = getArticleData(message);
|
||||
uni.navigateTo({
|
||||
url: `/pages/message/article-detail?id=${articleId}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取问卷数据
|
||||
const getSurveyData = (message) => {
|
||||
try {
|
||||
if (message.payload && message.payload.data) {
|
||||
const data = JSON.parse(message.payload.data);
|
||||
return {
|
||||
title: data.title || "填写问卷",
|
||||
desc: data.desc || "请填写问卷",
|
||||
url: data.url || "",
|
||||
imgUrl: data.imgUrl || "",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析问卷数据失败:", error);
|
||||
}
|
||||
return {
|
||||
title: "填写问卷",
|
||||
desc: "请填写问卷",
|
||||
url: "",
|
||||
imgUrl: "",
|
||||
};
|
||||
};
|
||||
|
||||
// 处理问卷点击
|
||||
const handleSurveyClick = (message) => {
|
||||
const surveyData = getSurveyData(message);
|
||||
if (surveyData.url) {
|
||||
// 跳转到问卷填写页面或打开外部链接
|
||||
console.log("打开问卷:", surveyData.url);
|
||||
// uni.navigateTo({
|
||||
// url: `/pages/survey/fill?url=${encodeURIComponent(surveyData.url)}`
|
||||
// });
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
</style>
|
||||
297
pages/message/components/reject-reason-modal.vue
Normal file
297
pages/message/components/reject-reason-modal.vue
Normal file
@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<view v-if="visible" class="modal-overlay" @click="handleCancel">
|
||||
<view class="modal-container" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">选择暂不接受咨询原因,供患者知晓</text>
|
||||
</view>
|
||||
|
||||
<view class="modal-content">
|
||||
<!-- 预设原因选项 -->
|
||||
<view class="reason-options">
|
||||
<view
|
||||
v-for="(option, index) in reasonOptions"
|
||||
:key="index"
|
||||
class="reason-option"
|
||||
:class="{ active: selectedReason === option }"
|
||||
@click="selectReason(option)"
|
||||
>
|
||||
<text class="option-text">{{ option }}</text>
|
||||
<view class="option-icon" :class="{ checked: selectedReason === option }">
|
||||
<text v-if="selectedReason === option" class="check-mark">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义输入选项 -->
|
||||
<view
|
||||
class="reason-option custom-option"
|
||||
:class="{ active: isCustomInput }"
|
||||
@click="selectCustomInput"
|
||||
>
|
||||
<text class="option-text">填写拒诊理由</text>
|
||||
<view class="option-icon arrow">
|
||||
<text class="arrow-icon">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 自定义输入框 -->
|
||||
<view v-if="isCustomInput" class="custom-input-container">
|
||||
<textarea
|
||||
class="custom-textarea"
|
||||
v-model="customReason"
|
||||
placeholder="请输入理由,供患者知晓"
|
||||
maxlength="200"
|
||||
:auto-height="true"
|
||||
/>
|
||||
<text class="char-count">{{ customReason.length }}/200</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<button class="btn-cancel" @click="handleCancel">取消</button>
|
||||
<button class="btn-confirm" @click="handleConfirm">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['confirm', 'cancel']);
|
||||
|
||||
// 预设的拒绝原因选项
|
||||
const reasonOptions = ref([
|
||||
'临时有紧急事务',
|
||||
'患者病情复杂,需线下就诊',
|
||||
]);
|
||||
|
||||
const selectedReason = ref('');
|
||||
const isCustomInput = ref(false);
|
||||
const customReason = ref('');
|
||||
|
||||
// 选择预设原因
|
||||
const selectReason = (option) => {
|
||||
selectedReason.value = option;
|
||||
isCustomInput.value = false;
|
||||
customReason.value = '';
|
||||
};
|
||||
|
||||
// 选择自定义输入
|
||||
const selectCustomInput = () => {
|
||||
selectedReason.value = '';
|
||||
isCustomInput.value = true;
|
||||
};
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
// 重置状态
|
||||
selectedReason.value = '';
|
||||
isCustomInput.value = false;
|
||||
customReason.value = '';
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
// 确定
|
||||
const handleConfirm = () => {
|
||||
let reason = '';
|
||||
|
||||
if (isCustomInput.value) {
|
||||
reason = customReason.value.trim();
|
||||
if (!reason) {
|
||||
uni.showToast({
|
||||
title: '请输入拒绝原因',
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
reason = selectedReason.value;
|
||||
if (!reason) {
|
||||
uni.showToast({
|
||||
title: '请选择拒绝原因',
|
||||
icon: 'none',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
emit('confirm', reason);
|
||||
|
||||
// 重置状态
|
||||
selectedReason.value = '';
|
||||
isCustomInput.value = false;
|
||||
customReason.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
width: 600rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 32rpx 32rpx 24rpx;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 24rpx 32rpx;
|
||||
max-height: 600rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.reason-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.reason-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
border: 2rpx solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.active {
|
||||
background-color: #e6f4ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #d9d9d9;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
|
||||
&.checked {
|
||||
background-color: #1677ff;
|
||||
border-color: #1677ff;
|
||||
}
|
||||
|
||||
&.arrow {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 40rpx;
|
||||
color: #999;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.custom-input-container {
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.custom-textarea {
|
||||
width: 100%;
|
||||
min-height: 120rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
padding: 24rpx 32rpx 32rpx;
|
||||
border-top: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.btn-cancel,
|
||||
.btn-confirm {
|
||||
flex: 1;
|
||||
height: 80rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 28rpx;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-cancel::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
background-color: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-confirm::after {
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
182
pages/message/components/special-message/evaluation.vue
Normal file
182
pages/message/components/special-message/evaluation.vue
Normal file
@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<!-- 医生满意度评价卡片 -->
|
||||
<view class="evaluation-card">
|
||||
<view class="evaluation-content">
|
||||
<view class="evaluation-icon">
|
||||
<uni-icons type="star-filled" size="24" color="#0877F1"></uni-icons>
|
||||
</view>
|
||||
<view class="evaluation-info">
|
||||
<text class="evaluation-title">医生满意度评价</text>
|
||||
<text class="evaluation-subtitle">您对医生的本次服务满意吗?</text>
|
||||
</view>
|
||||
<view class="evaluation-btn">
|
||||
<text class="btn-text" @click="openEvaluationPopup">评价</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 评价弹窗 -->
|
||||
<uni-popup ref="evaluationPopup" type="bottom" :mask-click="false" class="evaluation-popup-wrapper">
|
||||
<view class="evaluation-popup-container">
|
||||
<view class="evaluation-popup">
|
||||
<!-- 顶部指示条 -->
|
||||
<view class="popup-indicator"></view>
|
||||
|
||||
<view class="popup-close" @click="closeEvaluationPopup">
|
||||
<uni-icons type="closeempty" size="24" color="#999"></uni-icons>
|
||||
</view>
|
||||
|
||||
<view v-if="doctorInfo" class="doctor-info-section">
|
||||
<image class="doctor-avatar-large" :src="doctorInfo.avatar" mode="aspectFill">
|
||||
</image>
|
||||
<text class="doctor-name">{{ doctorInfo.name }}</text>
|
||||
<text class="doctor-dept">{{ doctorInfo.title }} | {{ doctorInfo.department }}</text>
|
||||
</view>
|
||||
|
||||
<view class="rating-section">
|
||||
<view class="rate-wrapper">
|
||||
<uni-rate v-model="evaluationRating" :max="5" :size="32" :margin="8" color="#e0e0e0" active-color="#0877F1"
|
||||
:disabled="false"></uni-rate>
|
||||
</view>
|
||||
<text class="rating-text" :class="{ 'no-rating': evaluationRating === 0 }">{{ ratingText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="comment-section">
|
||||
<textarea class="evaluation-textarea" v-model="evaluationComment" placeholder="分享您的就诊经历、治疗方式、治疗效果、医生对您的帮助。"
|
||||
:maxlength="1000" show-confirm-bar="false"></textarea>
|
||||
<view class="char-count">{{ evaluationComment.length }}/500</view>
|
||||
</view>
|
||||
|
||||
<view class="evaluation-footer">
|
||||
<button class="submit-evaluation-btn" @click="submitEvaluation">
|
||||
提交评价
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<!-- 评价完成提示弹窗 -->
|
||||
<uni-popup ref="evaluationSuccessPopup" type="bottom" :mask-click="false" class="evaluation-popup-wrapper">
|
||||
<view class="success-popup">
|
||||
<view class="popup-close" @click="closeEvaluationSuccessPopup">
|
||||
<uni-icons type="closeempty" size="24" color="#999"></uni-icons>
|
||||
</view>
|
||||
<view class="success-header-row">
|
||||
<view class="success-icon">
|
||||
<uni-icons type="checkmarkempty" size="48" color="#4CAF50"></uni-icons>
|
||||
</view>
|
||||
<text class="success-title">评价发布完成</text>
|
||||
</view>
|
||||
<view class="success-eval-box">
|
||||
<view class="success-subtitle">对医生的总评价</view>
|
||||
<view class="success-rating">
|
||||
<uni-rate :value="evaluationRating" :max="5" :size="24" :margin="4" color="#0877F1" active-color="#0877F1"
|
||||
:readonly="true" :touchable="false"></uni-rate>
|
||||
<text class="success-rating-text">{{ ratingText }}</text>
|
||||
</view>
|
||||
<view class="success-comment" v-if="evaluationComment">
|
||||
<text class="comment-text">{{ evaluationComment }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { getRate, submitRate } from '@/api/corp/rate';
|
||||
import { toast } from '@/utils/widget';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
doctorInfo: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
extension: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['evaluationSubmitted', 'popupStatusChange']);
|
||||
|
||||
// 评价相关状态
|
||||
const evaluationPopup = ref(null);
|
||||
const evaluationSuccessPopup = ref(null);
|
||||
const evaluationRating = ref(0);
|
||||
const evaluationComment = ref('');
|
||||
const record = ref(null)
|
||||
const loading = ref(false);
|
||||
|
||||
// 计算评分文字
|
||||
const ratingText = computed(() => {
|
||||
const texts = ['请选择评分', '很不满意', '不满意', '一般', '满意', '非常满意'];
|
||||
return texts[evaluationRating.value] || '请选择评分';
|
||||
});
|
||||
|
||||
// 打开评价弹窗
|
||||
const openEvaluationPopup = async () => {
|
||||
evaluationRating.value = 0
|
||||
evaluationComment.value = ''
|
||||
const res = await getRate(props.extension.rateId);
|
||||
if (res && res.success) {
|
||||
record.value = res.data;
|
||||
evaluationRating.value = typeof res.data.rate === 'number' ? res.data.rate : 0;
|
||||
evaluationComment.value = typeof res.data.words === 'string' ? res.data.words : '';
|
||||
if (record.value.status === 'init') {
|
||||
evaluationPopup.value.open();
|
||||
emit('popupStatusChange', true); // 通知父组件弹窗已打开
|
||||
} else {
|
||||
evaluationSuccessPopup.value.open();
|
||||
emit('popupStatusChange', true); // 通知父组件弹窗已打开
|
||||
}
|
||||
} else {
|
||||
toast('获取评价信息失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭评价弹窗
|
||||
const closeEvaluationPopup = () => {
|
||||
evaluationPopup.value.close();
|
||||
emit('popupStatusChange', false); // 通知父组件弹窗已关闭
|
||||
};
|
||||
|
||||
// 关闭评价完成弹窗
|
||||
const closeEvaluationSuccessPopup = () => {
|
||||
evaluationSuccessPopup.value.close();
|
||||
emit('popupStatusChange', false); // 通知父组件弹窗已关闭
|
||||
};
|
||||
|
||||
// 提交评价
|
||||
const submitEvaluation = async () => {
|
||||
if (evaluationRating.value === 0) {
|
||||
uni.showToast({ title: '请先评分', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
const res = await submitRate({
|
||||
id: props.extension.rateId,
|
||||
rate: evaluationRating.value,
|
||||
words: evaluationComment.value
|
||||
});
|
||||
if (res && res.success) {
|
||||
evaluationPopup.value.close();
|
||||
setTimeout(() => {
|
||||
evaluationSuccessPopup.value.open();
|
||||
// 弹窗仍然保持打开状态,不需要额外通知
|
||||
}, 600);
|
||||
} else {
|
||||
// 提交失败,弹窗关闭
|
||||
emit('popupStatusChange', false);
|
||||
}
|
||||
loading.value = false
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../chat.scss";
|
||||
</style>
|
||||
40
pages/message/components/special-message/index.vue
Normal file
40
pages/message/components/special-message/index.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<evaluation-message
|
||||
v-if="payload.description === 'PATIENT_RATE_MESSAGE'"
|
||||
:doctorInfo="doctorInfo"
|
||||
:extension="extension"
|
||||
@popupStatusChange="handlePopupStatusChange"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import evaluationMessage from './evaluation.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object
|
||||
},
|
||||
doctorInfo:{
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['popupStatusChange']);
|
||||
|
||||
const payload = computed(() => {
|
||||
return props.message && props.message.payload ? props.message.payload : {};
|
||||
})
|
||||
const extension = computed(() => {
|
||||
try {
|
||||
return JSON.parse(payload.value.extension)
|
||||
} catch (e) {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
|
||||
// 处理弹窗状态变化,传递给父组件
|
||||
const handlePopupStatusChange = (isOpen) => {
|
||||
emit('popupStatusChange', isOpen);
|
||||
}
|
||||
</script>
|
||||
158
pages/message/components/system-message.vue
Normal file
158
pages/message/components/system-message.vue
Normal file
@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<!-- <view v-if="notifyText" class="notify-bar">
|
||||
<view class="notify-line"></view>
|
||||
<view class="notify-text">{{ notifyText }}</view>
|
||||
<view class="notify-line"></view>
|
||||
</view> -->
|
||||
<view class="system-message">
|
||||
<view class="system-text">{{ text }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const payload = computed(() => props.message?.payload || {});
|
||||
|
||||
// 解析系统消息内容
|
||||
const systemMessageData = computed(() => {
|
||||
try {
|
||||
// 尝试从 payload.data 解析系统消息
|
||||
if (payload.value.data) {
|
||||
const data = typeof payload.value.data === 'string'
|
||||
? JSON.parse(payload.value.data)
|
||||
: payload.value.data;
|
||||
|
||||
if (data.type === 'system_message') {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析系统消息失败:', e);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 解析扩展信息
|
||||
const extension = computed(() => {
|
||||
try {
|
||||
if (payload.value.extension) {
|
||||
return typeof payload.value.extension === 'string'
|
||||
? JSON.parse(payload.value.extension)
|
||||
: payload.value.extension;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析扩展信息失败:', e);
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 显示的文本内容
|
||||
const text = computed(() => {
|
||||
// 优先从系统消息数据中获取文本
|
||||
if (systemMessageData.value?.text) {
|
||||
return systemMessageData.value.text;
|
||||
}
|
||||
|
||||
// 根据消息类型返回默认文本
|
||||
if (systemMessageData.value?.messageType) {
|
||||
const messageType = systemMessageData.value.messageType;
|
||||
switch (messageType) {
|
||||
case 'consult_pending':
|
||||
return '患者已发起咨询申请,请及时接诊';
|
||||
case 'consult_accepted':
|
||||
return '医生已接诊';
|
||||
case 'consult_rejected':
|
||||
return '医生暂时无法接诊';
|
||||
case 'consult_ended':
|
||||
return '问诊已结束';
|
||||
case 'consult_timeout':
|
||||
return '问诊已超时';
|
||||
default:
|
||||
return systemMessageData.value.content || '[系统消息]';
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧格式:从 extension 中获取
|
||||
if (extension.value.patient) {
|
||||
return extension.value.patient;
|
||||
}
|
||||
|
||||
// 兼容旧格式:直接从 payload.data 获取
|
||||
if (payload.value.data && typeof payload.value.data === 'string') {
|
||||
// 如果 data 不是 JSON 格式,直接显示
|
||||
try {
|
||||
JSON.parse(payload.value.data);
|
||||
} catch {
|
||||
return payload.value.data;
|
||||
}
|
||||
}
|
||||
|
||||
return '[系统消息]';
|
||||
});
|
||||
|
||||
// 通知文本(红色提示)
|
||||
const notifyText = computed(() => {
|
||||
// 从扩展信息中获取通知文本
|
||||
if (extension.value.notifyText) {
|
||||
return extension.value.notifyText;
|
||||
}
|
||||
|
||||
// 根据系统消息类型显示不同的通知
|
||||
if (systemMessageData.value) {
|
||||
const messageType = systemMessageData.value.messageType;
|
||||
switch (messageType) {
|
||||
case 'consult_pending':
|
||||
return '待接诊';
|
||||
case 'consult_rejected':
|
||||
return '已拒绝';
|
||||
case 'consult_timeout':
|
||||
return '已超时';
|
||||
case 'consult_accepted':
|
||||
return '已接诊';
|
||||
case 'consult_ended':
|
||||
return '已结束';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../chat.scss";
|
||||
|
||||
.notify-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30rpx;
|
||||
}
|
||||
|
||||
.notify-line {
|
||||
flex-grow: 1;
|
||||
min-width: 30rpx;
|
||||
height: 1px;
|
||||
background: #ddd;
|
||||
}
|
||||
|
||||
.notify-text {
|
||||
margin: 0 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: red;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
max-width: 80%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
62
pages/message/hooks/use-group-chat.js
Normal file
62
pages/message/hooks/use-group-chat.js
Normal file
@ -0,0 +1,62 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow, onUnload } from '@dcloudio/uni-app'
|
||||
|
||||
/**
|
||||
* 简单的群聊hook
|
||||
* @param {string} groupID 群组ID
|
||||
*/
|
||||
export default function useGroupChat(groupID) {
|
||||
const groupInfo = ref({})
|
||||
const members = ref([])
|
||||
|
||||
// 群聊成员映射
|
||||
const chatMember = computed(() => {
|
||||
const res = {}
|
||||
members.value.forEach(member => {
|
||||
res[member.id] = {
|
||||
name: member.name,
|
||||
avatar: member.avatar || '/static/default-avatar.png'
|
||||
}
|
||||
})
|
||||
return res
|
||||
})
|
||||
|
||||
// 获取群聊信息
|
||||
async function getGroupInfo() {
|
||||
const gid = typeof groupID === 'string' ? groupID : groupID.value
|
||||
if (!gid) return
|
||||
|
||||
try {
|
||||
// 这里可以调用API获取群聊信息
|
||||
// const res = await getGroupDetail(gid)
|
||||
// if (res && res.success) {
|
||||
// groupInfo.value = res.data
|
||||
// members.value = res.data.members || []
|
||||
// }
|
||||
|
||||
// 暂时使用本地数据
|
||||
groupInfo.value = {
|
||||
groupID: gid,
|
||||
name: '群聊',
|
||||
status: 'active'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取群聊信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
getGroupInfo()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
// 清理资源
|
||||
})
|
||||
|
||||
return {
|
||||
groupInfo,
|
||||
members,
|
||||
chatMember,
|
||||
getGroupInfo
|
||||
}
|
||||
}
|
||||
931
pages/message/index.vue
Normal file
931
pages/message/index.vue
Normal file
@ -0,0 +1,931 @@
|
||||
<template>
|
||||
<view class="chat-page">
|
||||
<!-- 聊天消息区域 -->
|
||||
<scroll-view
|
||||
class="chat-content"
|
||||
scroll-y="true"
|
||||
enhanced="true"
|
||||
bounces="false"
|
||||
:scroll-into-view="scrollIntoView"
|
||||
@scroll="onScroll"
|
||||
@scrolltoupper="handleScrollToUpper"
|
||||
ref="chatScrollView"
|
||||
>
|
||||
<!-- 加载更多提示 -->
|
||||
<view class="load-more-tip" v-if="messageList.length >= 15">
|
||||
<view class="loading" v-if="isLoadingMore">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<view class="load-tip" v-else-if="!isCompleted">
|
||||
<text class="tip-text">↑ 上滑加载更多</text>
|
||||
</view>
|
||||
<view class="load-tip" v-else>
|
||||
<text class="completed-text">已加载全部消息</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 聊天消息列表 -->
|
||||
<view class="message-list" @click="closeMorePanel">
|
||||
<view
|
||||
v-for="(message, index) in messageList"
|
||||
:key="message.ID"
|
||||
:id="`msg-${message.ID}`"
|
||||
class="message-item"
|
||||
:class="{
|
||||
'message-right': message.flow === 'out',
|
||||
'message-left': message.flow === 'in',
|
||||
}"
|
||||
>
|
||||
<!-- 时间分割线 -->
|
||||
<view
|
||||
v-if="shouldShowTime(message, index, messageList)"
|
||||
class="time-divider"
|
||||
>
|
||||
<text class="time-text">{{ formatTime(message.lastTime) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isSystemMessage(message)">
|
||||
<SystemMessage :message="message" />
|
||||
</view>
|
||||
|
||||
<!-- 消息内容 -->
|
||||
<view v-else class="message-content">
|
||||
<!-- 医生头像(左侧) -->
|
||||
<image
|
||||
v-if="message.flow === 'in'"
|
||||
class="doctor-msg-avatar"
|
||||
:src="
|
||||
chatMember[message.from]?.avatar || '/static/default-avatar.png'
|
||||
"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- 患者头像(右侧) -->
|
||||
<image
|
||||
v-if="message.flow === 'out'"
|
||||
class="user-msg-avatar"
|
||||
:src="
|
||||
chatMember[message.from]?.avatar || '/static/home/avatar.svg'
|
||||
"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<!-- 消息内容区域 -->
|
||||
<view class="message-bubble-container">
|
||||
<!-- 用户名显示 -->
|
||||
<view
|
||||
class="username-label"
|
||||
:class="{
|
||||
left: message.flow === 'in',
|
||||
right: message.flow === 'out',
|
||||
}"
|
||||
>
|
||||
<text class="username-text">{{
|
||||
chatMember[message.from]?.name
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
|
||||
<view class="message-bubble" :class="getBubbleClass(message)">
|
||||
<!-- 消息内容 -->
|
||||
<MessageTypes
|
||||
:message="message"
|
||||
:formatTime="formatTime"
|
||||
:playingVoiceId="playingVoiceId"
|
||||
@playVoice="playVoice"
|
||||
@previewImage="previewImage"
|
||||
@viewDetail="(message) => handleViewDetail(message)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 发送状态 -->
|
||||
<view v-if="message.flow === 'out'" class="message-status">
|
||||
<text
|
||||
v-if="message.status === 'failed'"
|
||||
class="status-text failed"
|
||||
>发送失败</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 取消申请组件 -->
|
||||
<ConsultCancel
|
||||
v-if="showConsultCancel"
|
||||
@cancel="handleCancelConsult"
|
||||
/>
|
||||
|
||||
<!-- 拒绝原因对话框 -->
|
||||
<RejectReasonModal
|
||||
:visible="showRejectReasonModal"
|
||||
@confirm="handleRejectReasonConfirm"
|
||||
@cancel="handleRejectReasonCancel"
|
||||
/>
|
||||
|
||||
<!-- 聊天输入组件 -->
|
||||
<ChatInput
|
||||
v-if="!isEvaluationPopupOpen && !showConsultCancel"
|
||||
ref="chatInputRef"
|
||||
:timChatManager="timChatManager"
|
||||
:formatTime="formatTime"
|
||||
:groupId="chatInfo.conversationID ? chatInfo.conversationID.replace('GROUP', '') : ''"
|
||||
:userId="openid"
|
||||
:corpId="corpId"
|
||||
@scrollToBottom="() => scrollToBottom(true)"
|
||||
@messageSent="() => scrollToBottom(true)"
|
||||
@endConsult="handleEndConsult"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted, nextTick, watch, computed } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import { globalTimChatManager } from "@/utils/tim-chat.js";
|
||||
import {
|
||||
startIMMonitoring,
|
||||
stopIMMonitoring,
|
||||
} from "@/utils/im-status-manager.js";
|
||||
import {
|
||||
getVoiceUrl,
|
||||
validateVoiceUrl,
|
||||
createAudioContext,
|
||||
showMessage,
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
previewImage,
|
||||
throttle,
|
||||
clearMessageCache,
|
||||
handleViewDetail,
|
||||
checkIMConnectionStatus,
|
||||
} from "@/utils/chat-utils.js";
|
||||
import api from "@/utils/api.js";
|
||||
import useGroupChat from "./hooks/use-group-chat";
|
||||
import MessageTypes from "./components/message-types.vue";
|
||||
import ChatInput from "./components/chat-input.vue";
|
||||
import SystemMessage from "./components/system-message.vue";
|
||||
import ConsultCancel from "./components/consult-cancel.vue";
|
||||
import RejectReasonModal from "./components/reject-reason-modal.vue";
|
||||
|
||||
const timChatManager = globalTimChatManager;
|
||||
|
||||
// 获取环境变量
|
||||
const env = __VITE_ENV__;
|
||||
const corpId = env.MP_CORP_ID || '';
|
||||
|
||||
// 获取登录状态
|
||||
const { account, openid, isIMInitialized } = storeToRefs(useAccountStore());
|
||||
const { initIMAfterLogin } = useAccountStore();
|
||||
|
||||
// 聊天输入组件引用
|
||||
const chatInputRef = ref(null);
|
||||
|
||||
const groupId = ref("");
|
||||
const { chatMember, getGroupInfo } = useGroupChat(groupId);
|
||||
|
||||
// 动态设置导航栏标题
|
||||
const updateNavigationTitle = () => {
|
||||
uni.setNavigationBarTitle({
|
||||
title: "群聊",
|
||||
});
|
||||
};
|
||||
|
||||
// 聊天信息
|
||||
const chatInfo = ref({
|
||||
conversationID: "",
|
||||
userID: "",
|
||||
avatar: "/static/home/avatar.svg",
|
||||
});
|
||||
|
||||
// 评价弹窗状态
|
||||
const isEvaluationPopupOpen = ref(false);
|
||||
|
||||
// 取消申请状态
|
||||
const showConsultCancel = ref(false);
|
||||
|
||||
// 拒绝原因对话框状态
|
||||
const showRejectReasonModal = ref(false);
|
||||
|
||||
// 消息列表相关状态
|
||||
const messageList = ref([]);
|
||||
const isLoading = ref(false);
|
||||
const scrollIntoView = ref("");
|
||||
|
||||
// 分页加载相关状态
|
||||
const isLoadingMore = ref(false);
|
||||
const isCompleted = ref(false);
|
||||
const lastFirstMessageId = ref("");
|
||||
|
||||
// 判断是否为系统消息
|
||||
function isSystemMessage(message) {
|
||||
if (message.type !== "TIMCustomElem") {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查 payload.data 是否包含系统消息标记
|
||||
if (message.payload?.data) {
|
||||
const data =
|
||||
typeof message.payload.data === "string"
|
||||
? JSON.parse(message.payload.data)
|
||||
: message.payload.data;
|
||||
|
||||
// 检查是否为系统消息类型
|
||||
if (data.type === "system_message") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 description 是否为系统消息标记
|
||||
if (message.payload?.description === "系统消息标记") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 兼容旧的系统消息格式
|
||||
if (message.payload?.description === "SYSTEM_NOTIFICATION") {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("判断系统消息失败:", error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查是否有待接诊的系统消息
|
||||
function checkConsultPendingStatus() {
|
||||
// 查找最新的系统消息
|
||||
for (let i = messageList.value.length - 1; i >= 0; i--) {
|
||||
const message = messageList.value[i];
|
||||
|
||||
if (message.type === "TIMCustomElem" && message.payload?.data) {
|
||||
try {
|
||||
const data =
|
||||
typeof message.payload.data === "string"
|
||||
? JSON.parse(message.payload.data)
|
||||
: message.payload.data;
|
||||
|
||||
// 如果是 consult_pending 类型的系统消息,显示取消申请组件
|
||||
if (
|
||||
data.type === "system_message" &&
|
||||
data.messageType === "consult_pending"
|
||||
) {
|
||||
showConsultCancel.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果是其他系统消息类型(如已接诊、已结束、已拒绝等),隐藏取消申请组件
|
||||
if (
|
||||
data.type === "system_message" &&
|
||||
(data.messageType === "consult_accepted" ||
|
||||
data.messageType === "consult_ended" ||
|
||||
data.messageType === "consult_rejected")
|
||||
) {
|
||||
showConsultCancel.value = false;
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("解析系统消息失败:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有找到相关系统消息,隐藏取消申请组件
|
||||
showConsultCancel.value = false;
|
||||
}
|
||||
|
||||
// 获取消息气泡样式类
|
||||
function getBubbleClass(message) {
|
||||
// 图片消息不需要气泡背景
|
||||
if (message.type === "TIMImageElem") {
|
||||
return "image-bubble";
|
||||
}
|
||||
if (message.type === "TIMCustomElem") {
|
||||
return message.flow === "out" ? "" : "";
|
||||
}
|
||||
return message.flow === "out" ? "user-bubble" : "doctor-bubble";
|
||||
}
|
||||
|
||||
// 页面加载
|
||||
onLoad((options) => {
|
||||
groupId.value = options.groupID || "";
|
||||
messageList.value = [];
|
||||
isLoading.value = false;
|
||||
if (options.conversationID) {
|
||||
chatInfo.value.conversationID = options.conversationID;
|
||||
timChatManager.setConversationID(options.conversationID);
|
||||
console.log("设置当前会话ID:", options.conversationID);
|
||||
}
|
||||
if (options.userID) {
|
||||
chatInfo.value.userID = options.userID;
|
||||
}
|
||||
|
||||
checkLoginAndInitTIM();
|
||||
updateNavigationTitle();
|
||||
});
|
||||
|
||||
// 检查登录状态并初始化IM
|
||||
const checkLoginAndInitTIM = async () => {
|
||||
if (!isIMInitialized.value) {
|
||||
uni.showLoading({
|
||||
title: "连接中...",
|
||||
});
|
||||
const success = await initIMAfterLogin(openid.value);
|
||||
uni.hideLoading();
|
||||
if (!success) {
|
||||
uni.showToast({
|
||||
title: "IM连接失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!timChatManager.isLoggedIn) {
|
||||
uni.showLoading({
|
||||
title: "重连中...",
|
||||
});
|
||||
const reconnected = await timChatManager.ensureIMConnection();
|
||||
uni.hideLoading();
|
||||
if (!reconnected) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
initTIMCallbacks();
|
||||
};
|
||||
|
||||
// 初始化IM回调函数
|
||||
const initTIMCallbacks = async () => {
|
||||
timChatManager.setCallback("onSDKReady", () => {
|
||||
if (messageList.value.length === 0 && !isLoading.value) {
|
||||
loadMessageList();
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onSDKNotReady", () => {});
|
||||
|
||||
timChatManager.setCallback("onMessageReceived", (message) => {
|
||||
console.log("页面收到消息:", {
|
||||
messageID: message.ID,
|
||||
conversationID: message.conversationID,
|
||||
currentConversationID: chatInfo.value.conversationID,
|
||||
type: message.type,
|
||||
flow: message.flow,
|
||||
});
|
||||
|
||||
// 验证消息属于当前群聊
|
||||
if (message.conversationID !== chatInfo.value.conversationID) {
|
||||
console.log("⚠️ 消息不属于当前群聊,已过滤");
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查消息是否已存在
|
||||
const existingMessage = messageList.value.find(
|
||||
(msg) => msg.ID === message.ID
|
||||
);
|
||||
if (!existingMessage) {
|
||||
messageList.value.push(message);
|
||||
console.log("✓ 添加消息到列表,当前消息数量:", messageList.value.length);
|
||||
|
||||
// 检查是否有待接诊的系统消息
|
||||
checkConsultPendingStatus();
|
||||
|
||||
// 立即滚动到底部,不使用延迟
|
||||
nextTick(() => {
|
||||
scrollToBottom(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onMessageSent", (data) => {
|
||||
const { messageID, status } = data;
|
||||
const message = messageList.value.find((msg) => msg.ID === messageID);
|
||||
if (message) {
|
||||
message.status = status;
|
||||
console.log("更新消息状态:", messageID, status);
|
||||
}
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onMessageListLoaded", (data) => {
|
||||
console.log("【onMessageListLoaded】收到消息列表回调");
|
||||
uni.hideLoading();
|
||||
|
||||
let messages = [];
|
||||
if (typeof data === "object" && data.messages) {
|
||||
messages = data.messages;
|
||||
} else {
|
||||
messages = data;
|
||||
}
|
||||
isLoading.value = false;
|
||||
|
||||
// 去重处理 + 严格过滤当前群聊的消息
|
||||
const uniqueMessages = [];
|
||||
const seenIds = new Set();
|
||||
|
||||
messages.forEach((message) => {
|
||||
const belongsToCurrentConversation =
|
||||
message.conversationID === chatInfo.value.conversationID;
|
||||
|
||||
if (
|
||||
message.ID &&
|
||||
!seenIds.has(message.ID) &&
|
||||
belongsToCurrentConversation
|
||||
) {
|
||||
seenIds.add(message.ID);
|
||||
uniqueMessages.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
messageList.value = uniqueMessages;
|
||||
console.log(
|
||||
"消息列表已更新,原始",
|
||||
messages.length,
|
||||
"条,过滤后",
|
||||
uniqueMessages.length,
|
||||
"条消息"
|
||||
);
|
||||
|
||||
isCompleted.value = data.isCompleted || false;
|
||||
isLoadingMore.value = false;
|
||||
|
||||
// 检查是否有待接诊的系统消息
|
||||
checkConsultPendingStatus();
|
||||
|
||||
nextTick(() => {
|
||||
if (data.isRefresh) {
|
||||
console.log("后台刷新完成,保持当前滚动位置");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.isPullUp && lastFirstMessageId.value) {
|
||||
console.log(
|
||||
"上拉加载完成,定位到加载前的第一条消息:",
|
||||
lastFirstMessageId.value
|
||||
);
|
||||
setTimeout(() => {
|
||||
scrollIntoView.value = `msg-${lastFirstMessageId.value}`;
|
||||
lastFirstMessageId.value = "";
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.isPullUp) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
setTimeout(() => {
|
||||
scrollToBottom();
|
||||
}, 100);
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
timChatManager.setCallback("onError", (error) => {
|
||||
console.error("TIM错误:", error);
|
||||
uni.showToast({
|
||||
title: error,
|
||||
icon: "none",
|
||||
});
|
||||
});
|
||||
|
||||
nextTick(() => {
|
||||
if (timChatManager.tim && timChatManager.isLoggedIn) {
|
||||
setTimeout(() => {
|
||||
loadMessageList();
|
||||
}, 50);
|
||||
} else if (timChatManager.tim) {
|
||||
let checkCount = 0;
|
||||
const checkInterval = setInterval(() => {
|
||||
checkCount++;
|
||||
if (timChatManager.isLoggedIn) {
|
||||
clearInterval(checkInterval);
|
||||
loadMessageList();
|
||||
} else if (checkCount > 10) {
|
||||
clearInterval(checkInterval);
|
||||
showMessage("IM登录超时,请重新进入");
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 加载消息列表
|
||||
const loadMessageList = () => {
|
||||
if (isLoading.value) {
|
||||
console.log("正在加载中,跳过重复加载");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"【loadMessageList】开始加载消息,会话ID:",
|
||||
chatInfo.value.conversationID
|
||||
);
|
||||
isLoading.value = true;
|
||||
|
||||
uni.showLoading({
|
||||
title: "加载中...",
|
||||
mask: false,
|
||||
});
|
||||
|
||||
timChatManager.enterConversation(chatInfo.value.conversationID || "test1");
|
||||
|
||||
// 标记会话为已读
|
||||
if (
|
||||
timChatManager.tim &&
|
||||
timChatManager.isLoggedIn &&
|
||||
chatInfo.value.conversationID
|
||||
) {
|
||||
timChatManager.tim
|
||||
.setMessageRead({
|
||||
conversationID: chatInfo.value.conversationID,
|
||||
})
|
||||
.then(() => {
|
||||
console.log("会话已标记为已读:", chatInfo.value.conversationID);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("标记会话已读失败:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 语音播放相关
|
||||
let currentAudioContext = null;
|
||||
const playingVoiceId = ref(null);
|
||||
|
||||
// 播放语音
|
||||
const playVoice = (message) => {
|
||||
if (playingVoiceId.value === message.ID && currentAudioContext) {
|
||||
currentAudioContext.stop();
|
||||
currentAudioContext.destroy();
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAudioContext) {
|
||||
currentAudioContext.stop();
|
||||
currentAudioContext.destroy();
|
||||
currentAudioContext = null;
|
||||
}
|
||||
|
||||
const voiceUrl = getVoiceUrl(message);
|
||||
if (!validateVoiceUrl(voiceUrl)) return;
|
||||
|
||||
playingVoiceId.value = message.ID;
|
||||
|
||||
currentAudioContext = createAudioContext(voiceUrl);
|
||||
currentAudioContext.onEnded(() => {
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
});
|
||||
currentAudioContext.onError(() => {
|
||||
currentAudioContext = null;
|
||||
playingVoiceId.value = null;
|
||||
});
|
||||
currentAudioContext.play();
|
||||
};
|
||||
|
||||
// 滚动到底部
|
||||
const scrollToBottom = (immediate = false) => {
|
||||
if (messageList.value.length > 0) {
|
||||
const lastMessage = messageList.value[messageList.value.length - 1];
|
||||
const targetId = `msg-${lastMessage.ID}`;
|
||||
|
||||
if (immediate) {
|
||||
// 立即滚动:先清空再设置,触发滚动
|
||||
scrollIntoView.value = "";
|
||||
nextTick(() => {
|
||||
scrollIntoView.value = targetId;
|
||||
});
|
||||
} else {
|
||||
// 正常滚动,使用短延迟确保DOM更新
|
||||
scrollIntoView.value = "";
|
||||
setTimeout(() => {
|
||||
scrollIntoView.value = targetId;
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 关闭功能栏
|
||||
const closeMorePanel = () => {
|
||||
uni.$emit("closeMorePanel");
|
||||
};
|
||||
|
||||
// 滚动事件
|
||||
const onScroll = throttle((e) => {
|
||||
// 滚动处理
|
||||
}, 100);
|
||||
|
||||
// 处理上滑加载更多
|
||||
const handleScrollToUpper = async () => {
|
||||
console.log("【handleScrollToUpper】触发上滑事件,准备加载更多");
|
||||
console.log(
|
||||
" 当前状态: isLoadingMore=",
|
||||
isLoadingMore.value,
|
||||
"isCompleted=",
|
||||
isCompleted.value
|
||||
);
|
||||
|
||||
if (isLoadingMore.value || isCompleted.value) {
|
||||
console.log(
|
||||
" ⏭️ 跳过加载:isLoadingMore=",
|
||||
isLoadingMore.value,
|
||||
"isCompleted=",
|
||||
isCompleted.value
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageList.value.length < 15) {
|
||||
console.log(" ⏭️ 消息数量不足15条,跳过加载更多");
|
||||
return;
|
||||
}
|
||||
|
||||
if (messageList.value.length > 0) {
|
||||
lastFirstMessageId.value = messageList.value[0].ID;
|
||||
console.log(" 📍 记录当前第一条消息ID:", lastFirstMessageId.value);
|
||||
}
|
||||
|
||||
isLoadingMore.value = true;
|
||||
|
||||
try {
|
||||
console.log(" 📡 调用 timChatManager.loadMoreMessages()");
|
||||
const result = await timChatManager.loadMoreMessages();
|
||||
|
||||
console.log(" 📥 加载结果:", result);
|
||||
|
||||
if (result.success) {
|
||||
console.log(` ✅ 加载更多成功,新增 ${result.count} 条消息`);
|
||||
} else {
|
||||
console.log(" ⚠️ 加载失败:", result.message || result.error);
|
||||
if (result.message === "已加载全部消息") {
|
||||
console.log(" ✅ 已加载全部消息");
|
||||
isCompleted.value = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(" ❌ 加载更多失败:", error);
|
||||
uni.showToast({
|
||||
title: "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
isLoadingMore.value = false;
|
||||
console.log(" 🏁 加载完成,isLoadingMore 设置为 false");
|
||||
}
|
||||
};
|
||||
|
||||
// 页面显示
|
||||
onShow(() => {
|
||||
if (!account.value || !openid.value) {
|
||||
uni.redirectTo({
|
||||
url: "/pages-center/login/login",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIMInitialized.value) {
|
||||
checkLoginAndInitTIM();
|
||||
} else if (timChatManager.tim && !timChatManager.isLoggedIn) {
|
||||
timChatManager.ensureIMConnection();
|
||||
}
|
||||
|
||||
startIMMonitoring(30000);
|
||||
});
|
||||
|
||||
// 页面隐藏
|
||||
onHide(() => {
|
||||
stopIMMonitoring();
|
||||
});
|
||||
|
||||
|
||||
const sendCommonPhrase = (content) => {
|
||||
if (chatInputRef.value) {
|
||||
chatInputRef.value.sendTextMessageFromPhrase(content);
|
||||
}
|
||||
};
|
||||
// 暴露方法给常用语页面调用
|
||||
defineExpose({
|
||||
sendCommonPhrase,
|
||||
});
|
||||
|
||||
// 处理取消申请
|
||||
const handleCancelConsult = async () => {
|
||||
try {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定要取消咨询申请吗?',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({
|
||||
title: "处理中...",
|
||||
});
|
||||
|
||||
try {
|
||||
// 发送取消申请的系统消息
|
||||
const customMessage = {
|
||||
data: JSON.stringify({
|
||||
type: "system_message",
|
||||
messageType: "consult_cancelled",
|
||||
content: "患者已取消咨询申请",
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
description: "系统消息标记",
|
||||
extension: "",
|
||||
};
|
||||
|
||||
const message = timChatManager.tim.createCustomMessage({
|
||||
to: chatInfo.value.conversationID.replace("GROUP", ""),
|
||||
conversationType: timChatManager.TIM.TYPES.CONV_GROUP,
|
||||
payload: customMessage,
|
||||
});
|
||||
|
||||
const sendResult = await timChatManager.tim.sendMessage(message);
|
||||
|
||||
if (sendResult.code === 0) {
|
||||
showConsultCancel.value = false;
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: "已取消申请",
|
||||
icon: "success",
|
||||
duration: 1500,
|
||||
});
|
||||
|
||||
// 延迟返回首页
|
||||
setTimeout(() => {
|
||||
uni.switchTab({
|
||||
url: "/pages/home/home",
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
throw new Error(sendResult.message || "发送失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("取消申请失败:", error);
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: error.message || "操作失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("取消申请失败:", error);
|
||||
uni.showToast({
|
||||
title: error.message || "操作失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理结束问诊
|
||||
const handleEndConsult = async () => {
|
||||
try {
|
||||
uni.showLoading({
|
||||
title: "处理中...",
|
||||
});
|
||||
// 调用结束问诊接口,直接传入 groupId
|
||||
const result = await api('endConsultation', {
|
||||
groupId: groupId.value,
|
||||
adminAccount: account.value?.userId || "",
|
||||
extraData: {
|
||||
endBy: account.value?.userId || "",
|
||||
endByName: account.value?.name || "医生",
|
||||
endReason: "问诊完成",
|
||||
},
|
||||
});
|
||||
uni.hideLoading();
|
||||
if (result.success) {
|
||||
uni.showToast({
|
||||
title: "问诊已结束",
|
||||
icon: "success",
|
||||
});
|
||||
} else {
|
||||
throw new Error(result.message || "操作失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("结束问诊失败:", error);
|
||||
uni.hideLoading();
|
||||
uni.showToast({
|
||||
title: error.message || "操作失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 页面卸载
|
||||
onUnmounted(() => {
|
||||
clearMessageCache();
|
||||
|
||||
timChatManager.setCallback("onSDKReady", null);
|
||||
timChatManager.setCallback("onSDKNotReady", null);
|
||||
timChatManager.setCallback("onMessageReceived", null);
|
||||
timChatManager.setCallback("onMessageListLoaded", null);
|
||||
timChatManager.setCallback("onError", null);
|
||||
// 移除问卷发送监听
|
||||
uni.$off("sendSurvey");
|
||||
});
|
||||
|
||||
|
||||
// 监听问卷发送事件
|
||||
uni.$on("sendSurvey", async (data) => {
|
||||
const { survey, corpId, userId, sendSurveyId } = data;
|
||||
|
||||
if (!survey || !survey._id) {
|
||||
uni.showToast({
|
||||
title: "问卷信息不完整",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 获取环境变量
|
||||
const env = __VITE_ENV__;
|
||||
const baseUrl = env.VITE_PATIENT_PAGE_BASE_URL || "";
|
||||
const surveyUrl = env.VITE_SURVEY_URL || "";
|
||||
|
||||
// 获取客户信息
|
||||
const customerId = chatInfo.value.userID || "";
|
||||
const customerName = chatInfo.value.customerName || "";
|
||||
|
||||
// 创建问卷记录
|
||||
const { createSurveyRecord } = await import("@/utils/api.js");
|
||||
const recordRes = await createSurveyRecord({
|
||||
corpId,
|
||||
userId,
|
||||
surveryId: survey._id,
|
||||
memberId: customerId,
|
||||
customer: customerName,
|
||||
sendSurveyId,
|
||||
});
|
||||
|
||||
if (!recordRes.success) {
|
||||
throw new Error(recordRes.message || "创建问卷记录失败");
|
||||
}
|
||||
|
||||
const answerId = recordRes.data?.id || "";
|
||||
|
||||
// 构建问卷链接
|
||||
let surveyLink = "";
|
||||
if (survey.createBy === "system") {
|
||||
// 系统问卷
|
||||
surveyLink = `${surveyUrl}?corpId=${corpId}&surveyId=${survey.surveyId}&memberId=${customerId}&sendSurveyId=${sendSurveyId}&userId=${userId}`;
|
||||
} else {
|
||||
// 自定义问卷
|
||||
surveyLink = `${baseUrl}pages/survery/fill?corpId=${corpId}&surveryId=${
|
||||
survey._id
|
||||
}&memberId=${customerId}&answerId=${answerId}&name=${encodeURIComponent(
|
||||
customerName
|
||||
)}`;
|
||||
}
|
||||
|
||||
// 创建自定义消息
|
||||
const customMessage = {
|
||||
data: JSON.stringify({
|
||||
type: "survey",
|
||||
title: survey.name || "填写问卷",
|
||||
desc: "请填写问卷",
|
||||
url: surveyLink,
|
||||
imgUrl:
|
||||
"https://796f-youcan-clouddev-1-8ewcqf31dbb2b5-1317294507.tcb.qcloud.la/other/19-%E9%97%AE%E5%8D%B7.png?sign=55a4cd77c418b2c548b65792a2cf6bce&t=1701328694",
|
||||
}),
|
||||
description: "SURVEY",
|
||||
extension: "",
|
||||
};
|
||||
|
||||
// 发送自定义消息
|
||||
const message = timChatManager.tim.createCustomMessage({
|
||||
to: chatInfo.value.conversationID.replace("GROUP", ""),
|
||||
conversationType: timChatManager.TIM.TYPES.CONV_GROUP,
|
||||
payload: customMessage,
|
||||
});
|
||||
|
||||
const sendResult = await timChatManager.tim.sendMessage(message);
|
||||
|
||||
if (sendResult.code === 0) {
|
||||
uni.showToast({
|
||||
title: "发送成功",
|
||||
icon: "success",
|
||||
});
|
||||
} else {
|
||||
throw new Error(sendResult.message || "发送失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("发送问卷失败:", error);
|
||||
uni.showToast({
|
||||
title: error.message || "发送失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./chat.scss";
|
||||
</style>
|
||||
@ -1,9 +1,539 @@
|
||||
<template>
|
||||
<div>message</div>
|
||||
<view class="message-page">
|
||||
<!-- 消息列表 -->
|
||||
<scroll-view
|
||||
class="message-list"
|
||||
scroll-y="true"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
@scrolltolower="handleLoadMore"
|
||||
>
|
||||
<!-- 加载状态 -->
|
||||
<view
|
||||
v-if="loading && conversationList.length === 0"
|
||||
class="loading-container"
|
||||
>
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- 消息列表项 -->
|
||||
<view
|
||||
v-for="conversation in conversationList"
|
||||
:key="conversation.conversationID"
|
||||
class="message-item"
|
||||
@click="handleClickConversation(conversation)"
|
||||
>
|
||||
<view class="avatar-container">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="conversation.avatar || '/static/default-avatar.png'"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-if="conversation.unreadCount > 0" class="unread-badge">
|
||||
<text class="unread-text">{{
|
||||
conversation.unreadCount > 99 ? "99+" : conversation.unreadCount
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content">
|
||||
<view class="header">
|
||||
<text class="name">{{ conversation.name || "未知群聊" }}</text>
|
||||
<text class="time">{{
|
||||
formatMessageTime(conversation.lastMessageTime)
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="message-preview">
|
||||
<text class="preview-text">{{
|
||||
conversation.lastMessage || "暂无消息"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view
|
||||
v-if="!loading && conversationList.length === 0"
|
||||
class="empty-container"
|
||||
>
|
||||
<image class="empty-image" src="/static/empty.svg" mode="aspectFit" />
|
||||
<text class="empty-text">暂无消息</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="hasMore && conversationList.length > 0" class="load-more">
|
||||
<text class="load-more-text">{{
|
||||
loadingMore ? "加载中..." : "上拉加载更多"
|
||||
}}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import { storeToRefs } from "pinia";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import { globalTimChatManager } from "@/utils/tim-chat.js";
|
||||
|
||||
// 获取登录状态
|
||||
const { account, openid, isIMInitialized } = storeToRefs(useAccountStore());
|
||||
const { initIMAfterLogin } = useAccountStore();
|
||||
|
||||
// 状态
|
||||
const conversationList = ref([]);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const refreshing = ref(false);
|
||||
|
||||
// 初始化IM
|
||||
const initIM = async () => {
|
||||
if (!isIMInitialized.value) {
|
||||
uni.showLoading({
|
||||
title: "连接中...",
|
||||
});
|
||||
const success = await initIMAfterLogin(openid.value);
|
||||
uni.hideLoading();
|
||||
|
||||
if (!success) {
|
||||
uni.showToast({
|
||||
title: "IM连接失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
} else if (globalTimChatManager && !globalTimChatManager.isLoggedIn) {
|
||||
uni.showLoading({
|
||||
title: "重连中...",
|
||||
});
|
||||
const reconnected = await globalTimChatManager.ensureIMConnection();
|
||||
uni.hideLoading();
|
||||
|
||||
if (!reconnected) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 加载会话列表
|
||||
const loadConversationList = async () => {
|
||||
if (loading.value) return;
|
||||
// loading.value = true;
|
||||
|
||||
try {
|
||||
console.log("开始加载群聊列表");
|
||||
if (!globalTimChatManager || !globalTimChatManager.getGroupList) {
|
||||
throw new Error("IM管理器未初始化");
|
||||
}
|
||||
// 直接调用getGroupList,它会自动等待SDK就绪
|
||||
const result = await globalTimChatManager.getGroupList();
|
||||
if (result && result.success && result.groupList) {
|
||||
conversationList.value = result.groupList
|
||||
.map((group) => ({
|
||||
conversationID: group.conversationID || `GROUP${group.groupID}`,
|
||||
groupID: group.groupID,
|
||||
name: group.patientName
|
||||
? `${group.patientName}的问诊`
|
||||
: group.name || "问诊群聊",
|
||||
avatar: group.avatar || "/static/default-avatar.png",
|
||||
lastMessage: group.lastMessage || "暂无消息",
|
||||
lastMessageTime: group.lastMessageTime || Date.now(),
|
||||
unreadCount: group.unreadCount || 0,
|
||||
doctorId: group.doctorId,
|
||||
patientName: group.patientName,
|
||||
}))
|
||||
.sort((a, b) => b.lastMessageTime - a.lastMessageTime);
|
||||
|
||||
console.log(
|
||||
"群聊列表加载成功,共",
|
||||
conversationList.value.length,
|
||||
"个会话"
|
||||
);
|
||||
} else {
|
||||
console.error("加载群聊列表失败:", result);
|
||||
uni.showToast({
|
||||
title: "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载会话列表失败:", error);
|
||||
uni.showToast({
|
||||
title: error.message || "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 提取消息预览文本
|
||||
const extractMessagePreview = (message) => {
|
||||
if (!message) return "暂无消息";
|
||||
|
||||
const payload = message.payload;
|
||||
if (!payload) return "暂无消息";
|
||||
|
||||
// 文本消息
|
||||
if (message.type === "TIMTextElem") {
|
||||
return payload.text || "暂无消息";
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if (message.type === "TIMImageElem") {
|
||||
return "[图片]";
|
||||
}
|
||||
|
||||
// 语音消息
|
||||
if (message.type === "TIMSoundElem") {
|
||||
return "[语音]";
|
||||
}
|
||||
|
||||
// 视频消息
|
||||
if (message.type === "TIMVideoFileElem") {
|
||||
return "[视频]";
|
||||
}
|
||||
|
||||
// 文件消息
|
||||
if (message.type === "TIMFileElem") {
|
||||
return "[文件]";
|
||||
}
|
||||
|
||||
// 自定义消息
|
||||
if (message.type === "TIMCustomElem") {
|
||||
const description = payload.description;
|
||||
if (description === "SYSTEM_NOTIFICATION") {
|
||||
return "[系统消息]";
|
||||
}
|
||||
return "[消息]";
|
||||
}
|
||||
|
||||
return "暂无消息";
|
||||
};
|
||||
|
||||
// 设置会话列表监听,实时更新列表
|
||||
const setupConversationListener = () => {
|
||||
if (!globalTimChatManager) return;
|
||||
|
||||
// 监听会话列表更新事件
|
||||
globalTimChatManager.setCallback("onConversationListUpdated", (eventData) => {
|
||||
console.log("会话列表更新事件:", eventData);
|
||||
|
||||
// 如果是新消息导致的会话更新
|
||||
if (
|
||||
eventData.reason === "NEW_MESSAGE_RECEIVED_IN_CURRENT_CONVERSATION" ||
|
||||
eventData.reason === "NEW_MESSAGE_RECEIVED"
|
||||
) {
|
||||
const conversation = eventData.conversation;
|
||||
if (!conversation) return;
|
||||
|
||||
const conversationID = conversation.conversationID;
|
||||
const conversationIndex = conversationList.value.findIndex(
|
||||
(conv) => conv.conversationID === conversationID
|
||||
);
|
||||
|
||||
if (conversationIndex !== -1) {
|
||||
// 更新现有会话
|
||||
const existingConversation = conversationList.value[conversationIndex];
|
||||
existingConversation.lastMessage =
|
||||
conversation.lastMessage || "暂无消息";
|
||||
existingConversation.lastMessageTime =
|
||||
conversation.lastMessageTime || Date.now();
|
||||
existingConversation.unreadCount = conversation.unreadCount || 0;
|
||||
|
||||
// 将该会话移到顶部
|
||||
const [updatedConversation] = conversationList.value.splice(
|
||||
conversationIndex,
|
||||
1
|
||||
);
|
||||
conversationList.value.unshift(updatedConversation);
|
||||
|
||||
console.log("已更新会话:", existingConversation.name);
|
||||
} else {
|
||||
// 新会话,添加到列表顶部
|
||||
conversationList.value.unshift({
|
||||
conversationID: conversationID,
|
||||
groupID: conversation.groupID || conversationID.replace("GROUP", ""),
|
||||
name: conversation.name || "问诊群聊",
|
||||
avatar: conversation.avatar || "/static/default-avatar.png",
|
||||
lastMessage: conversation.lastMessage || "暂无消息",
|
||||
lastMessageTime: conversation.lastMessageTime || Date.now(),
|
||||
unreadCount: conversation.unreadCount || 0,
|
||||
});
|
||||
|
||||
console.log("已添加新会话");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听消息接收事件(用于更新未读数)
|
||||
globalTimChatManager.setCallback("onMessageReceived", (message) => {
|
||||
console.log("消息列表页面收到新消息:", message);
|
||||
|
||||
// 找到对应的会话并更新未读数
|
||||
const conversationID = message.conversationID;
|
||||
const conversationIndex = conversationList.value.findIndex(
|
||||
(conv) => conv.conversationID === conversationID
|
||||
);
|
||||
|
||||
if (conversationIndex !== -1) {
|
||||
const conversation = conversationList.value[conversationIndex];
|
||||
// 只更新未读数,其他信息由 onConversationListUpdated 事件处理
|
||||
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
|
||||
console.log("已更新会话未读数:", conversation.name);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 格式化消息时间
|
||||
const formatMessageTime = (timestamp) => {
|
||||
if (!timestamp) return "";
|
||||
|
||||
const now = Date.now();
|
||||
const diff = now - timestamp;
|
||||
const date = new Date(timestamp);
|
||||
|
||||
// 1分钟内
|
||||
if (diff < 60 * 1000) {
|
||||
return "刚刚";
|
||||
}
|
||||
|
||||
// 1小时内
|
||||
if (diff < 60 * 60 * 1000) {
|
||||
return `${Math.floor(diff / (60 * 1000))}分钟前`;
|
||||
}
|
||||
|
||||
// 今天
|
||||
const today = new Date();
|
||||
if (date.toDateString() === today.toDateString()) {
|
||||
return `${String(date.getHours()).padStart(2, "0")}:${String(
|
||||
date.getMinutes()
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// 昨天
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return "昨天";
|
||||
}
|
||||
|
||||
// 今年
|
||||
if (date.getFullYear() === today.getFullYear()) {
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
// 其他
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
};
|
||||
|
||||
// 点击会话
|
||||
const handleClickConversation = (conversation) => {
|
||||
console.log("点击会话:", conversation);
|
||||
|
||||
// 跳转到聊天页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/message/index?conversationID=${conversation.conversationID}&groupID=${conversation.groupID}`,
|
||||
});
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = () => {
|
||||
if (loadingMore.value || !hasMore.value) return;
|
||||
|
||||
loadingMore.value = true;
|
||||
// TODO: 实现分页加载
|
||||
setTimeout(() => {
|
||||
loadingMore.value = false;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 下拉刷新
|
||||
const handleRefresh = async () => {
|
||||
refreshing.value = true;
|
||||
|
||||
try {
|
||||
await loadConversationList();
|
||||
} finally {
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载
|
||||
onLoad(() => {
|
||||
console.log("消息列表页面加载");
|
||||
});
|
||||
|
||||
// 页面显示
|
||||
onShow(async () => {
|
||||
try {
|
||||
// 初始化IM
|
||||
const imReady = await initIM();
|
||||
if (!imReady) {
|
||||
console.error("IM初始化失败");
|
||||
return;
|
||||
}
|
||||
|
||||
// 先加载初始会话列表
|
||||
await loadConversationList();
|
||||
|
||||
// 再设置监听器,后续通过事件更新列表
|
||||
setupConversationListener();
|
||||
} catch (error) {
|
||||
console.error("页面初始化失败:", error);
|
||||
uni.showToast({
|
||||
title: "初始化失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 页面隐藏
|
||||
onHide(() => {
|
||||
// 移除消息监听
|
||||
if (globalTimChatManager) {
|
||||
globalTimChatManager.setCallback("onConversationListUpdated", null);
|
||||
globalTimChatManager.setCallback("onMessageReceived", null);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-page {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.message-list {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-image {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.message-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 32rpx;
|
||||
background-color: #fff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
|
||||
&:active {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.unread-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -8rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
background-color: #ff4d4f;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.unread-text {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
padding: 20rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
446
pages/message/survey-list.vue
Normal file
446
pages/message/survey-list.vue
Normal file
@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<view class="survey-page">
|
||||
<view class="header">
|
||||
<view class="search-bar">
|
||||
<uni-icons type="search" size="18" color="#999" />
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchName"
|
||||
placeholder="输入问卷名称搜索"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="content">
|
||||
<view class="category-sidebar">
|
||||
<scroll-view scroll-y class="category-scroll">
|
||||
<view
|
||||
v-for="cate in categoryList"
|
||||
:key="cate._id || 'all'"
|
||||
class="category-item"
|
||||
:class="{ active: currentCateId === cate._id }"
|
||||
@click="selectCategory(cate)"
|
||||
>
|
||||
{{ cate.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="survey-list">
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="survey-scroll"
|
||||
@scrolltolower="loadMore"
|
||||
lower-threshold="50"
|
||||
>
|
||||
<view
|
||||
v-if="loading && surveyList.length === 0"
|
||||
class="loading-container"
|
||||
>
|
||||
<uni-icons type="spinner-cycle" size="30" color="#999" />
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="surveyList.length === 0" class="empty-container">
|
||||
<empty-data :title="emptyText || '暂无问卷'" />
|
||||
</view>
|
||||
|
||||
<view v-else>
|
||||
<view
|
||||
v-for="survey in surveyList"
|
||||
:key="survey._id"
|
||||
class="survey-item"
|
||||
>
|
||||
<view class="survey-content" @click="previewSurvey(survey)">
|
||||
<text class="survey-title">{{ survey.name }}</text>
|
||||
<text class="survey-desc">{{
|
||||
survey.description || "暂无问卷说明"
|
||||
}}</text>
|
||||
</view>
|
||||
<view class="survey-action">
|
||||
<button
|
||||
class="send-btn"
|
||||
size="mini"
|
||||
type="primary"
|
||||
@click="sendSurvey(survey)"
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="loading && surveyList.length > 0" class="loading-more">
|
||||
<uni-icons type="spinner-cycle" size="20" color="#999" />
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!loading && surveyList.length >= total" class="no-more">
|
||||
没有更多了
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import {
|
||||
getSurveyCateList,
|
||||
getSurveyList,
|
||||
createSurveyRecord,
|
||||
} from "@/utils/api.js";
|
||||
import useAccountStore from "@/store/account.js";
|
||||
import EmptyData from "@/components/empty-data.vue";
|
||||
|
||||
const env = __VITE_ENV__;
|
||||
const accountStore = useAccountStore();
|
||||
const corpId = env.MP_CORP_ID;
|
||||
const userId = ref("");
|
||||
|
||||
// 搜索关键词
|
||||
const searchName = ref("");
|
||||
let searchTimer = null;
|
||||
|
||||
// 分类列表
|
||||
const categoryList = ref([{ _id: "", label: "全部" }]);
|
||||
const currentCateId = ref("");
|
||||
|
||||
// 问卷列表
|
||||
const surveyList = ref([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 30;
|
||||
const total = ref(0);
|
||||
const emptyText = ref("");
|
||||
|
||||
// 获取分类列表
|
||||
const getCategoryList = async () => {
|
||||
try {
|
||||
const res = await getSurveyCateList({ corpId: corpId });
|
||||
if (res.success && res.list) {
|
||||
const cates = res.list || [];
|
||||
categoryList.value = [{ _id: "", label: "全部" }, ...cates];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取分类列表失败:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 选择分类
|
||||
const selectCategory = (cate) => {
|
||||
currentCateId.value = cate._id || "";
|
||||
page.value = 1;
|
||||
surveyList.value = [];
|
||||
loadSurveyList();
|
||||
};
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = setTimeout(() => {
|
||||
page.value = 1;
|
||||
surveyList.value = [];
|
||||
loadSurveyList();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// 加载问卷列表
|
||||
const loadSurveyList = async () => {
|
||||
if (loading.value) return;
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
corpId: corpId,
|
||||
page: page.value,
|
||||
pageSize: pageSize,
|
||||
name: searchName.value.trim(),
|
||||
status: "enable",
|
||||
showCount: false,
|
||||
};
|
||||
|
||||
// 如果选择了分类,添加分类ID
|
||||
if (currentCateId.value) {
|
||||
params.cateIds = [currentCateId.value];
|
||||
}
|
||||
|
||||
const res = await getSurveyList(params);
|
||||
if (res.success && res) {
|
||||
const { list = [], total: count = 0 } = res;
|
||||
|
||||
if (page.value === 1) {
|
||||
surveyList.value = list;
|
||||
} else {
|
||||
surveyList.value = [...surveyList.value, ...list];
|
||||
}
|
||||
total.value = count;
|
||||
emptyText.value = "暂无问卷信息";
|
||||
} else {
|
||||
emptyText.value = res.message || "加载失败";
|
||||
uni.showToast({
|
||||
title: res.message || "获取问卷列表失败",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载问卷列表失败:", error);
|
||||
emptyText.value = "加载失败";
|
||||
uni.showToast({
|
||||
title: "加载失败,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
if (loading.value || surveyList.value.length >= total.value) return;
|
||||
page.value += 1;
|
||||
loadSurveyList();
|
||||
};
|
||||
|
||||
// 预览问卷
|
||||
const previewSurvey = (survey) => {
|
||||
const timestamp = Date.now();
|
||||
const previewUrl = `https://www.youcan365.com/surveyDev/#/pages/survey/survey?surveyId=${survey.surveyId}&t=${timestamp}`;
|
||||
|
||||
// #ifdef H5
|
||||
window.open(previewUrl, '_blank');
|
||||
// #endif
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.navigateTo({
|
||||
url: `/pages/webview/webview?url=${encodeURIComponent(previewUrl)}`
|
||||
});
|
||||
// #endif
|
||||
|
||||
// #ifdef APP-PLUS
|
||||
plus.runtime.openURL(previewUrl);
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 生成随机字符串
|
||||
const generateRandomString = (length) => {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// 发送问卷
|
||||
const sendSurvey = async (survey) => {
|
||||
if (loading.value) return;
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
// 获取医生信息
|
||||
const doctorInfo = accountStore.doctorInfo;
|
||||
userId.value = doctorInfo?.userid || accountStore.openid;
|
||||
|
||||
// 生成发送ID
|
||||
const sendSurveyId = generateRandomString(10);
|
||||
|
||||
// 获取当前聊天的客户信息(从上一页传递)
|
||||
const pages = getCurrentPages();
|
||||
const prevPage = pages[pages.length - 2];
|
||||
|
||||
// 这里需要从聊天页面获取客户信息
|
||||
// 暂时使用事件传递方式
|
||||
uni.$emit("sendSurvey", {
|
||||
survey: survey,
|
||||
corpId: corpId,
|
||||
userId: userId.value,
|
||||
sendSurveyId: sendSurveyId,
|
||||
});
|
||||
|
||||
uni.showToast({
|
||||
title: "已选择问卷",
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
// 延迟返回
|
||||
setTimeout(() => {
|
||||
uni.navigateBack();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("发送问卷失败:", error);
|
||||
uni.showToast({
|
||||
title: error.message || "发送失败",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getCategoryList();
|
||||
loadSurveyList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.survey-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
padding: 20rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
margin-left: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.category-sidebar {
|
||||
width: 200rpx;
|
||||
background-color: #f8f8f8;
|
||||
border-right: 1px solid #eee;
|
||||
}
|
||||
|
||||
.category-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
.category-item {
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-item.active {
|
||||
background-color: #fff;
|
||||
color: #0877f1;
|
||||
font-weight: bold;
|
||||
border-left: 4rpx solid #0877f1;
|
||||
}
|
||||
|
||||
.survey-list {
|
||||
flex: 1;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.survey-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 20rpx;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.survey-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 30rpx;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.survey-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.survey-title {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.survey-desc {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.survey-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.loading-more,
|
||||
.no-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #fff;
|
||||
padding: 20rpx;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
</style>
|
||||
@ -2,6 +2,7 @@ import { ref } from "vue";
|
||||
import { defineStore } from "pinia";
|
||||
import api from '@/utils/api';
|
||||
import { toast } from '@/utils/widget';
|
||||
import { initGlobalTIM, globalTimChatManager } from "@/utils/tim-chat.js";
|
||||
const env = __VITE_ENV__;
|
||||
|
||||
|
||||
@ -9,8 +10,10 @@ export default defineStore("accountStore", () => {
|
||||
const appid = env.MP_WX_APP_ID;
|
||||
const account = ref(null);
|
||||
const loading = ref(false)
|
||||
|
||||
const isIMInitialized = ref(false);
|
||||
const openid = ref("");
|
||||
async function login(phoneCode = '') {
|
||||
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
|
||||
@ -28,6 +31,8 @@ export default defineStore("accountStore", () => {
|
||||
loading.value = false
|
||||
if (res.success && res.data && res.data.mobile) {
|
||||
account.value = res.data;
|
||||
openid.value = res.data.openid;
|
||||
initIMAfterLogin(openid.value)
|
||||
return res.data
|
||||
}
|
||||
}
|
||||
@ -38,5 +43,37 @@ export default defineStore("accountStore", () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { account, login }
|
||||
async function initIMAfterLogin(userID) {
|
||||
if (isIMInitialized.value) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await initGlobalTIM(userID);
|
||||
isIMInitialized.value = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('IM初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// 退出登录
|
||||
async function logout() {
|
||||
try {
|
||||
// 退出腾讯IM
|
||||
if (globalTimChatManager && globalTimChatManager.tim) {
|
||||
console.log('开始退出腾讯IM');
|
||||
await globalTimChatManager.destroy();
|
||||
console.log('腾讯IM退出成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('退出腾讯IM失败:', error);
|
||||
}
|
||||
|
||||
// 清空账户信息
|
||||
account.value = null;
|
||||
openid.value = "";
|
||||
isIMInitialized.value = false;
|
||||
}
|
||||
|
||||
return { account, login, initIMAfterLogin, logout, openid, isIMInitialized }
|
||||
})
|
||||
30
utils/api.js
30
utils/api.js
@ -14,7 +14,23 @@ const urlsConfig = {
|
||||
|
||||
knowledgeBase: {
|
||||
getArticleByIds: 'getArticleByIds',
|
||||
getPageDisease: "getPageDisease"
|
||||
getCommonPhrases: 'getCommonPhrases',
|
||||
saveCommonPhrase: 'saveCommonPhrase',
|
||||
deleteCommonPhrase: 'deleteCommonPhrase',
|
||||
getCommonPhraseCategories: 'getCommonPhraseCategories',
|
||||
saveCommonPhraseCategory: 'saveCommonPhraseCategory',
|
||||
// 个人常用语接口
|
||||
getPersonalPhrases: 'getPersonalPhrases',
|
||||
savePersonalPhrase: 'savePersonalPhrase',
|
||||
deletePersonalPhrase: 'deletePersonalPhrase',
|
||||
getPersonalPhraseCategories: 'getPersonalPhraseCategories',
|
||||
savePersonalPhraseCategory: 'savePersonalPhraseCategory',
|
||||
deletePersonalPhraseCategory: 'deletePersonalPhraseCategory',
|
||||
// 宣教文章接口
|
||||
getArticleCateList: 'getArticleCateList',
|
||||
getArticleList: 'getArticleList',
|
||||
getArticle: 'getArticle',
|
||||
addArticleSendRecord: 'addArticleSendRecord'
|
||||
},
|
||||
member: {
|
||||
addCustomer: 'add',
|
||||
@ -32,6 +48,15 @@ const urlsConfig = {
|
||||
},
|
||||
wecom: {
|
||||
addContactWay: 'addContactWay'
|
||||
},
|
||||
im: {
|
||||
getUserSig: 'getUserSig',
|
||||
sendSystemMessage: "sendSystemMessage",
|
||||
getChatRecordsByGroupId: "getChatRecordsByGroupId",
|
||||
sendConsultRejectedMessage: "sendConsultRejectedMessage",
|
||||
endConsultation: "endConsultation",
|
||||
getGroupListByGroupId: "getGroupListByGroupId",
|
||||
createConsultGroup: "createConsultGroup"
|
||||
}
|
||||
}
|
||||
const urls = Object.keys(urlsConfig).reduce((acc, path) => {
|
||||
@ -61,4 +86,5 @@ export default async function api(urlId, data = {}, loading = true) {
|
||||
type,
|
||||
}
|
||||
}, loading)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
798
utils/chat-utils.js
Normal file
798
utils/chat-utils.js
Normal file
@ -0,0 +1,798 @@
|
||||
/**
|
||||
* 聊天相关工具函数
|
||||
*/
|
||||
|
||||
// 通用消息提示
|
||||
export const showMessage = (title, icon = 'none') => {
|
||||
uni.showToast({
|
||||
title,
|
||||
icon,
|
||||
});
|
||||
};
|
||||
|
||||
// 检查问诊状态
|
||||
export const checkConsultationStatus = (waitingForDoctor, consultationEnded) => {
|
||||
if (waitingForDoctor) {
|
||||
showMessage("等待医生接诊中,无法发送消息");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (consultationEnded) {
|
||||
showMessage("问诊已结束,无法发送消息");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 检查IM连接状态
|
||||
export const checkIMConnection = (timChatManager) => {
|
||||
if (!timChatManager.tim || !timChatManager.isLoggedIn) {
|
||||
// showMessage("IM连接异常,请重新进入");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 发送消息前的通用验证
|
||||
export const validateBeforeSend = (waitingForDoctor, consultationEnded, timChatManager) => {
|
||||
if (!checkConsultationStatus(waitingForDoctor, consultationEnded)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!checkIMConnection(timChatManager)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 获取语音文件URL
|
||||
export const getVoiceUrl = (message) => {
|
||||
let voiceUrl = '';
|
||||
if (message.payload && message.payload.url) {
|
||||
voiceUrl = message.payload.url;
|
||||
} else if (message.payload && message.payload.file) {
|
||||
voiceUrl = message.payload.file;
|
||||
} else if (message.payload && message.payload.tempFilePath) {
|
||||
voiceUrl = message.payload.tempFilePath;
|
||||
} else if (message.payload && message.payload.filePath) {
|
||||
voiceUrl = message.payload.filePath;
|
||||
}
|
||||
return voiceUrl;
|
||||
};
|
||||
|
||||
// 验证语音URL格式
|
||||
export const validateVoiceUrl = (voiceUrl) => {
|
||||
if (!voiceUrl) {
|
||||
console.error('语音文件URL不存在');
|
||||
showMessage('语音文件不存在');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!voiceUrl.startsWith('http') && !voiceUrl.startsWith('wxfile://') && !voiceUrl.startsWith('/')) {
|
||||
console.error('语音文件URL格式不正确:', voiceUrl);
|
||||
showMessage('语音文件格式错误');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 创建音频上下文
|
||||
export const createAudioContext = (voiceUrl) => {
|
||||
const audioContext = uni.createInnerAudioContext();
|
||||
audioContext.src = voiceUrl;
|
||||
|
||||
audioContext.onPlay(() => {
|
||||
console.log('语音开始播放');
|
||||
});
|
||||
|
||||
audioContext.onEnded(() => {
|
||||
console.log('语音播放结束');
|
||||
});
|
||||
|
||||
audioContext.onError((err) => {
|
||||
console.error('语音播放失败:', err);
|
||||
console.error('错误详情:', {
|
||||
errMsg: err.errMsg,
|
||||
errno: err.errno,
|
||||
src: voiceUrl
|
||||
});
|
||||
showMessage('语音播放失败');
|
||||
});
|
||||
|
||||
return audioContext;
|
||||
};
|
||||
|
||||
// ==================== 时间相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 验证时间戳格式
|
||||
* @param {number|string} timestamp - 时间戳
|
||||
* @returns {boolean} 是否为有效时间戳
|
||||
*/
|
||||
export const validateTimestamp = (timestamp) => {
|
||||
if (!timestamp) return false;
|
||||
|
||||
const num = Number(timestamp);
|
||||
if (isNaN(num)) return false;
|
||||
|
||||
// 检查是否为有效的时间戳范围(1970年到2100年)
|
||||
const minTimestamp = 0;
|
||||
const maxTimestamp = 4102444800000; // 2100年1月1日
|
||||
|
||||
return num >= minTimestamp && num <= maxTimestamp;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化时间 - 今天/昨天显示文字,其他显示日期 + 空格 + 24小时制时间
|
||||
* @param {number|string} timestamp - 时间戳
|
||||
* @returns {string} 格式化后的时间字符串
|
||||
*/
|
||||
export const formatTime = (timestamp) => {
|
||||
// 验证时间戳
|
||||
if (!validateTimestamp(timestamp)) {
|
||||
return "未知时间";
|
||||
}
|
||||
|
||||
// 确保时间戳是毫秒级
|
||||
let timeInMs = timestamp;
|
||||
if (timestamp < 1000000000000) {
|
||||
// 如果时间戳小于这个值,可能是秒级时间戳
|
||||
timeInMs = timestamp * 1000;
|
||||
}
|
||||
|
||||
const date = new Date(timeInMs);
|
||||
const now = new Date();
|
||||
|
||||
// 验证日期是否有效
|
||||
if (isNaN(date.getTime())) {
|
||||
return "未知时间";
|
||||
}
|
||||
|
||||
// 格式化时间:HH:MM (24小时制)
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const timeStr = `${hours}:${minutes}`;
|
||||
|
||||
// 检查是否是今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return `${timeStr}`;
|
||||
}
|
||||
|
||||
// 检查是否是昨天
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return `昨天 ${timeStr}`;
|
||||
}
|
||||
|
||||
// 其他日期显示完整日期
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const dateStr = `${month}/${day}`;
|
||||
|
||||
return `${dateStr} ${timeStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算时间差
|
||||
* @param {number|string} startTime - 开始时间戳
|
||||
* @param {number|string} endTime - 结束时间戳
|
||||
* @returns {object} 包含天、小时、分钟、秒的时间差对象
|
||||
*/
|
||||
export const calculateTimeDiff = (startTime, endTime) => {
|
||||
if (!validateTimestamp(startTime) || !validateTimestamp(endTime)) {
|
||||
return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||||
}
|
||||
|
||||
let startMs = startTime;
|
||||
let endMs = endTime;
|
||||
|
||||
if (startTime < 1000000000000) startMs = startTime * 1000;
|
||||
if (endTime < 1000000000000) endMs = endTime * 1000;
|
||||
|
||||
const diffMs = Math.abs(endMs - startMs);
|
||||
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||
|
||||
return { days, hours, minutes, seconds };
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化倒计时
|
||||
* @param {number|string} endTime - 结束时间戳
|
||||
* @param {number|string} currentTime - 当前时间戳(可选,默认使用当前时间)
|
||||
* @returns {string} 格式化后的倒计时字符串
|
||||
*/
|
||||
export const formatCountdown = (endTime, currentTime = Date.now()) => {
|
||||
const diff = calculateTimeDiff(currentTime, endTime);
|
||||
|
||||
if (diff.days > 0) {
|
||||
return `${diff.days}天${diff.hours}时${diff.minutes}分`;
|
||||
} else if (diff.hours > 0) {
|
||||
return `${diff.hours}时${diff.minutes}分${diff.seconds}秒`;
|
||||
} else if (diff.minutes > 0) {
|
||||
return `${diff.minutes}分${diff.seconds}秒`;
|
||||
} else {
|
||||
return `${diff.seconds}秒`;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 媒体选择相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查并请求相册权限
|
||||
* @returns {Promise<boolean>} 是否有权限
|
||||
*/
|
||||
const checkAlbumPermission = () => {
|
||||
return new Promise((resolve) => {
|
||||
uni.getSetting({
|
||||
success: (res) => {
|
||||
const authStatus = res.authSetting['scope.album'];
|
||||
|
||||
if (authStatus === undefined) {
|
||||
// 未授权过,会自动弹出授权窗口
|
||||
resolve(true);
|
||||
} else if (authStatus === false) {
|
||||
// 已拒绝授权,需要引导用户手动开启
|
||||
uni.showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请在设置中开启相册权限,以便选择图片',
|
||||
confirmText: '去设置',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
uni.openSetting({
|
||||
success: (settingRes) => {
|
||||
if (settingRes.authSetting['scope.album']) {
|
||||
resolve(true);
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
resolve(false);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 已授权
|
||||
resolve(true);
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
// 获取设置失败,尝试直接调用
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择媒体文件
|
||||
* @param {object} options - 选择选项
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const chooseMedia = async (options, onSuccess, onFail) => {
|
||||
// 如果需要从相册选择,先检查权限
|
||||
const sourceType = options.sourceType || ['album', 'camera'];
|
||||
if (sourceType.includes('album')) {
|
||||
const hasPermission = await checkAlbumPermission();
|
||||
if (!hasPermission) {
|
||||
console.log('用户未授予相册权限');
|
||||
if (onFail) {
|
||||
onFail({ errMsg: '未授权相册权限' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
uni.chooseMedia({
|
||||
count: options.count || 1,
|
||||
mediaType: options.mediaType || ['image'],
|
||||
sizeType: options.sizeType || ['original', 'compressed'],
|
||||
sourceType: sourceType,
|
||||
success: function (res) {
|
||||
console.log('选择媒体成功:', res);
|
||||
if (onSuccess) onSuccess(res);
|
||||
},
|
||||
fail: function (err) {
|
||||
// 用户取消选择
|
||||
if (err.errMsg.includes('cancel')) {
|
||||
console.log('用户取消选择');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限相关错误
|
||||
if (err.errMsg.includes('permission') || err.errMsg.includes('auth') || err.errMsg.includes('拒绝')) {
|
||||
console.error('相册权限被拒绝:', err);
|
||||
uni.showModal({
|
||||
title: '需要相册权限',
|
||||
content: '请在设置中开启相册权限后重试',
|
||||
confirmText: '去设置',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
uni.openSetting();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (onFail) {
|
||||
onFail(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
console.error('选择媒体失败:', err);
|
||||
if (onFail) {
|
||||
onFail(err);
|
||||
} else {
|
||||
showMessage('选择图片失败,请重试');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择图片
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const chooseImage = (onSuccess, onFail) => {
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera']
|
||||
}, onSuccess, onFail);
|
||||
};
|
||||
|
||||
/**
|
||||
* 拍照
|
||||
* @param {function} onSuccess - 成功回调
|
||||
* @param {function} onFail - 失败回调
|
||||
*/
|
||||
export const takePhoto = (onSuccess, onFail) => {
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['camera']
|
||||
}, onSuccess, onFail);
|
||||
};
|
||||
|
||||
// ==================== 录音相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 初始化录音管理器
|
||||
* @param {object} options - 录音选项
|
||||
* @param {function} onStop - 录音结束回调
|
||||
* @param {function} onError - 录音错误回调
|
||||
* @returns {object} 录音管理器实例
|
||||
*/
|
||||
export const initRecorderManager = (options = {}, onStop, onError) => {
|
||||
const recorderManager = wx.getRecorderManager();
|
||||
|
||||
// 监听录音结束事件
|
||||
recorderManager.onStop((res) => {
|
||||
console.log('录音成功,结果:', res);
|
||||
if (onStop) onStop(res);
|
||||
});
|
||||
|
||||
// 监听录音错误事件
|
||||
recorderManager.onError((err) => {
|
||||
console.error('录音失败:', err);
|
||||
if (onError) {
|
||||
onError(err);
|
||||
} else {
|
||||
showMessage("录音失败");
|
||||
}
|
||||
});
|
||||
|
||||
return recorderManager;
|
||||
};
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
* @param {object} recorderManager - 录音管理器
|
||||
* @param {object} options - 录音参数
|
||||
*/
|
||||
export const startRecord = (recorderManager, options = {}) => {
|
||||
if (!recorderManager) {
|
||||
console.error('录音管理器未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
const recordOptions = {
|
||||
duration: 60000, // 录音的时长,单位 ms,最大值 600000(10 分钟)
|
||||
sampleRate: 44100, // 采样率
|
||||
numberOfChannels: 1, // 录音通道数
|
||||
encodeBitRate: 192000, // 编码码率
|
||||
format: 'aac', // 音频格式
|
||||
...options
|
||||
};
|
||||
|
||||
recorderManager.start(recordOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* 停止录音
|
||||
* @param {object} recorderManager - 录音管理器
|
||||
*/
|
||||
export const stopRecord = (recorderManager) => {
|
||||
if (!recorderManager) {
|
||||
console.error('录音管理器未初始化');
|
||||
return;
|
||||
}
|
||||
recorderManager.stop();
|
||||
};
|
||||
|
||||
// ==================== 消息发送相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 创建自定义消息
|
||||
* @param {string} messageType - 消息类型
|
||||
* @param {object} data - 消息数据
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 自定义消息对象
|
||||
*/
|
||||
export const createCustomMessage = (messageType, data, formatTime) => {
|
||||
return {
|
||||
messageType,
|
||||
time: formatTime(Date.now()),
|
||||
...data
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送自定义消息的通用方法
|
||||
* @param {object} messageData - 消息数据
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} validateBeforeSend - 发送前验证函数
|
||||
* @param {function} onSuccess - 成功回调
|
||||
*/
|
||||
export const sendCustomMessage = async (messageData, timChatManager, validateBeforeSend, onSuccess) => {
|
||||
if (!validateBeforeSend()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await timChatManager.sendCustomMessage(messageData);
|
||||
|
||||
if (result && result.success) {
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
console.error('发送自定义消息失败:', result?.error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送消息的通用方法
|
||||
* @param {string} messageType - 消息类型
|
||||
* @param {any} data - 消息数据
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} validateBeforeSend - 发送前验证函数
|
||||
* @param {function} onSuccess - 成功回调
|
||||
*/
|
||||
export const sendMessage = async (messageType, data, timChatManager, validateBeforeSend, onSuccess, cloudCustomData) => {
|
||||
if (!validateBeforeSend()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
switch (messageType) {
|
||||
case 'text':
|
||||
result = await timChatManager.sendTextMessage(data, cloudCustomData);
|
||||
break;
|
||||
case 'image':
|
||||
result = await timChatManager.sendImageMessage(data, cloudCustomData);
|
||||
break;
|
||||
case 'voice':
|
||||
result = await timChatManager.sendVoiceMessage(data.file, data.duration,cloudCustomData);
|
||||
break;
|
||||
default:
|
||||
console.error('未知的消息类型:', messageType);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.success) {
|
||||
if (onSuccess) onSuccess();
|
||||
} else {
|
||||
console.error('发送消息失败:', result?.error);
|
||||
showMessage('发送失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 状态检查相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查IM连接状态
|
||||
* @param {object} timChatManager - IM管理器
|
||||
* @param {function} onError - 错误回调
|
||||
* @returns {boolean} 连接状态
|
||||
*/
|
||||
export const checkIMConnectionStatus = (timChatManager, onError) => {
|
||||
if (!timChatManager.tim || !timChatManager.isLoggedIn) {
|
||||
const errorMsg = "IM连接异常,请重新进入";
|
||||
if (onError) {
|
||||
onError(errorMsg);
|
||||
} else {
|
||||
showMessage(errorMsg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查是否显示时间分割线
|
||||
* @param {object} message - 当前消息
|
||||
* @param {number} index - 消息索引
|
||||
* @param {Array} messageList - 消息列表
|
||||
* @returns {boolean} 是否显示时间分割线
|
||||
*/
|
||||
export const shouldShowTime = (message, index, messageList) => {
|
||||
if (index === 0) return true;
|
||||
|
||||
const prevMessage = messageList[index - 1];
|
||||
|
||||
// 使用工具函数验证时间戳
|
||||
if (!validateTimestamp(message.lastTime) || !validateTimestamp(prevMessage.lastTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeDiff = message.lastTime - prevMessage.lastTime;
|
||||
|
||||
return timeDiff > 5 * 60 * 1000; // 5分钟显示一次时间
|
||||
};
|
||||
|
||||
/**
|
||||
* 预览图片
|
||||
* @param {string} url - 图片URL
|
||||
*/
|
||||
export const previewImage = (url) => {
|
||||
uni.previewImage({
|
||||
urls: [url],
|
||||
current: url,
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== 录音相关工具方法 ====================
|
||||
|
||||
/**
|
||||
* 检查录音时长并处理
|
||||
* @param {object} res - 录音结果
|
||||
* @param {Function} onTimeTooShort - 时间太短的回调
|
||||
* @returns {boolean} 录音时长是否有效
|
||||
*/
|
||||
export const checkRecordingDuration = (res, onTimeTooShort = null) => {
|
||||
const duration = Math.floor(res.duration / 1000);
|
||||
if (duration < 1) {
|
||||
console.log('录音时间太短,取消发送');
|
||||
if (onTimeTooShort) {
|
||||
onTimeTooShort();
|
||||
} else {
|
||||
showMessage('说话时间太短');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// ==================== 防抖和节流工具 ====================
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export const debounce = (func, wait = 300) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} limit - 限制时间(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export const throttle = (func, limit = 300) => {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 自定义消息解析相关工具方法 ====================
|
||||
|
||||
// 自定义消息解析缓存
|
||||
const customMessageCache = new Map();
|
||||
|
||||
/**
|
||||
* 解析自定义消息(带缓存)
|
||||
* @param {object} message - 消息对象
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 解析后的消息对象
|
||||
*/
|
||||
export const parseCustomMessage = (message, formatTime) => {
|
||||
// 使用消息ID作为缓存键
|
||||
const cacheKey = message.ID;
|
||||
|
||||
// 检查缓存
|
||||
if (customMessageCache.has(cacheKey)) {
|
||||
return customMessageCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const customData = JSON.parse(message.payload.data);
|
||||
const parsedMessage = {
|
||||
messageType: customData.messageType,
|
||||
content: customData.content,
|
||||
symptomContent: customData.symptomContent,
|
||||
hasVisitedHospital: customData.hasVisitedHospital,
|
||||
selectedDiseases: customData.selectedDiseases,
|
||||
images: customData.images,
|
||||
medicines: customData.medicines,
|
||||
diagnosis: customData.diagnosis,
|
||||
prescriptionType: customData.prescriptionType,
|
||||
prescriptionDesc: customData.prescriptionDesc,
|
||||
tcmPrescription: customData.tcmPrescription, // 新增中药处方字段
|
||||
patientName: customData.patientName,
|
||||
gender: customData.gender,
|
||||
age: customData.age,
|
||||
surveyTitle: customData.surveyTitle,
|
||||
surveyDescription: customData.surveyDescription,
|
||||
surveyName: customData.surveyName,
|
||||
estimatedTime: customData.estimatedTime,
|
||||
reward: customData.reward,
|
||||
note: customData.note,
|
||||
orderId: customData.orderId, // 新增订单ID字段
|
||||
timestamp: customData.timestamp, // 新增时间戳字段
|
||||
conversationID: message.conversationID, // 保留conversationID
|
||||
time: formatTime(message.lastTime),
|
||||
};
|
||||
|
||||
// 缓存解析结果
|
||||
customMessageCache.set(cacheKey, parsedMessage);
|
||||
return parsedMessage;
|
||||
} catch (error) {
|
||||
const fallbackMessage = {
|
||||
messageType: "unknown",
|
||||
content: "未知消息类型",
|
||||
};
|
||||
|
||||
// 缓存错误结果,避免重复解析
|
||||
customMessageCache.set(cacheKey, fallbackMessage);
|
||||
return fallbackMessage;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理消息缓存
|
||||
*/
|
||||
export const clearMessageCache = () => {
|
||||
customMessageCache.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取解析后的自定义消息(带缓存)
|
||||
* @param {object} message - 消息对象
|
||||
* @param {function} formatTime - 时间格式化函数
|
||||
* @returns {object} 解析后的消息对象
|
||||
*/
|
||||
export const getParsedCustomMessage = (message, formatTime) => {
|
||||
return parseCustomMessage(message, formatTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理查看详情
|
||||
* @param {object} message - 解析后的消息对象
|
||||
* @param {object} patientInfo - 患者信息
|
||||
*/
|
||||
export const handleViewDetail = (message, patientInfo) => {
|
||||
if (message.messageType === "symptom") {
|
||||
uni.showModal({
|
||||
title: "完整病情描述",
|
||||
content: message.symptomContent,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "prescription") {
|
||||
// 处理处方单详情查看
|
||||
let content = `患者:${patientInfo.name}\n诊断:${message.diagnosis || '无'}\n\n`;
|
||||
|
||||
if (message.prescriptionType === '中药处方' && message.tcmPrescription) {
|
||||
content += `处方类型:中药处方\n处方详情:${message.tcmPrescription.description}\n`;
|
||||
if (message.tcmPrescription.usage) {
|
||||
content += `用法用量:${message.tcmPrescription.usage}\n`;
|
||||
}
|
||||
} else if (message.prescriptionType === '西药处方' && message.medicines) {
|
||||
content += `处方类型:西药处方\n药品清单:\n`;
|
||||
const medicineDetails = message.medicines
|
||||
.map((med) => `${med.name} ${med.spec} ×${med.count}`)
|
||||
.join("\n");
|
||||
content += medicineDetails + "\n";
|
||||
|
||||
// 添加用法用量
|
||||
const usageDetails = message.medicines
|
||||
.filter(med => med.usage)
|
||||
.map(med => `${med.name}:${med.usage}`)
|
||||
.join("\n");
|
||||
if (usageDetails) {
|
||||
content += `\n用法用量:\n${usageDetails}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
content += `\n开方时间:${message.time}`;
|
||||
|
||||
uni.showModal({
|
||||
title: "处方详情",
|
||||
content: content,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "refill") {
|
||||
// 处理续方申请详情查看
|
||||
let content = `患者:${message.patientName} ${message.gender} ${message.age}岁\n诊断:${message.diagnosis}\n\n`;
|
||||
|
||||
if (message.prescriptionType === "中药处方") {
|
||||
content += `处方类型:${message.prescriptionType}\n处方详情:${message.prescriptionDesc}`;
|
||||
} else {
|
||||
const medicineDetails = message.medicines
|
||||
.map((med) => `${med.name} ${med.spec} ${med.count}\n${med.usage}`)
|
||||
.join("\n\n");
|
||||
content += `药品清单:\n${medicineDetails}`;
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: "续方申请详情",
|
||||
content: content,
|
||||
showCancel: false,
|
||||
confirmText: "知道了",
|
||||
});
|
||||
} else if (message.messageType === "survey") {
|
||||
// 处理问卷调查详情查看或跳转
|
||||
uni.showModal({
|
||||
title: "问卷调查",
|
||||
content: `${message.surveyTitle}\n\n${message.surveyDescription
|
||||
}\n\n问卷名称:${message.surveyName}\n预计用时:${message.estimatedTime}${message.reward ? "\n完成奖励:" + message.reward : ""
|
||||
}${message.note ? "\n\n说明:" + message.note : ""}`,
|
||||
confirmText: "去填写",
|
||||
cancelText: "稍后再说",
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 这里可以跳转到问卷页面
|
||||
uni.showToast({
|
||||
title: "正在跳转到问卷页面",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
228
utils/im-status-manager.js
Normal file
228
utils/im-status-manager.js
Normal file
@ -0,0 +1,228 @@
|
||||
import {
|
||||
checkGlobalIMStatus,
|
||||
ensureGlobalIMConnection,
|
||||
getGlobalIMLoginStatus
|
||||
} from './tim-chat.js'
|
||||
|
||||
/**
|
||||
* 全局IM状态管理器
|
||||
* 提供统一的IM状态检测和管理接口
|
||||
*/
|
||||
class IMStatusManager {
|
||||
constructor() {
|
||||
this.statusCheckInterval = null
|
||||
this.isMonitoring = false
|
||||
this.checkIntervalTime = 60000 // 默认1分钟检查一次
|
||||
this.lastCheckTime = 0
|
||||
this.callbacks = {
|
||||
onStatusChange: [],
|
||||
onReconnectSuccess: [],
|
||||
onReconnectFailed: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动IM状态监控
|
||||
* @param {number} intervalTime 检查间隔时间(毫秒)
|
||||
*/
|
||||
startMonitoring(intervalTime = 60000) {
|
||||
if (this.isMonitoring) {
|
||||
console.log('IM状态监控已在运行')
|
||||
return
|
||||
}
|
||||
|
||||
this.checkIntervalTime = intervalTime
|
||||
this.isMonitoring = true
|
||||
|
||||
// 立即检查一次
|
||||
this.checkIMStatus()
|
||||
|
||||
// 启动定时检查
|
||||
this.statusCheckInterval = setInterval(() => {
|
||||
this.checkIMStatus()
|
||||
}, this.checkIntervalTime)
|
||||
|
||||
console.log(`IM状态监控已启动,检查间隔:${intervalTime / 1000}秒`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止IM状态监控
|
||||
*/
|
||||
stopMonitoring() {
|
||||
if (this.statusCheckInterval) {
|
||||
clearInterval(this.statusCheckInterval)
|
||||
this.statusCheckInterval = null
|
||||
}
|
||||
this.isMonitoring = false
|
||||
console.log('IM状态监控已停止')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查IM状态
|
||||
*/
|
||||
async checkIMStatus() {
|
||||
const now = Date.now()
|
||||
this.lastCheckTime = now
|
||||
|
||||
try {
|
||||
console.log('执行IM状态检查...')
|
||||
const isLoggedIn = checkGlobalIMStatus()
|
||||
|
||||
// 触发状态变化回调
|
||||
this.triggerCallbacks('onStatusChange', {
|
||||
isLoggedIn,
|
||||
checkTime: now,
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
|
||||
if (!isLoggedIn) {
|
||||
console.log('检测到IM未登录,尝试重连...')
|
||||
await this.attemptReconnect()
|
||||
} else {
|
||||
console.log('IM状态正常')
|
||||
}
|
||||
|
||||
return isLoggedIn
|
||||
} catch (error) {
|
||||
console.error('IM状态检查异常:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试重连IM
|
||||
*/
|
||||
async attemptReconnect() {
|
||||
try {
|
||||
console.log('开始尝试IM重连...')
|
||||
const success = await ensureGlobalIMConnection()
|
||||
|
||||
if (success) {
|
||||
console.log('IM重连成功')
|
||||
this.triggerCallbacks('onReconnectSuccess', {
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
|
||||
} else {
|
||||
console.log('IM重连失败')
|
||||
this.triggerCallbacks('onReconnectFailed', {
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
}
|
||||
|
||||
return success
|
||||
} catch (error) {
|
||||
console.error('IM重连异常:', error)
|
||||
this.triggerCallbacks('onReconnectFailed', {
|
||||
error,
|
||||
timestamp: new Date().toLocaleString()
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发IM连接检查
|
||||
*/
|
||||
async forceCheck() {
|
||||
console.log('手动触发IM连接检查')
|
||||
return await this.checkIMStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前IM登录状态
|
||||
*/
|
||||
getCurrentStatus() {
|
||||
return {
|
||||
isLoggedIn: getGlobalIMLoginStatus(),
|
||||
isMonitoring: this.isMonitoring,
|
||||
lastCheckTime: this.lastCheckTime,
|
||||
checkInterval: this.checkIntervalTime
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加状态变化回调
|
||||
*/
|
||||
onStatusChange(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onStatusChange.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加重连成功回调
|
||||
*/
|
||||
onReconnectSuccess(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onReconnectSuccess.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加重连失败回调
|
||||
*/
|
||||
onReconnectFailed(callback) {
|
||||
if (typeof callback === 'function') {
|
||||
this.callbacks.onReconnectFailed.push(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除回调
|
||||
*/
|
||||
removeCallback(type, callback) {
|
||||
if (this.callbacks[type]) {
|
||||
const index = this.callbacks[type].indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.callbacks[type].splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发回调
|
||||
*/
|
||||
triggerCallbacks(type, data) {
|
||||
if (this.callbacks[type]) {
|
||||
this.callbacks[type].forEach(callback => {
|
||||
try {
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error(`执行${type}回调失败:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态报告
|
||||
*/
|
||||
getStatusReport() {
|
||||
const status = this.getCurrentStatus()
|
||||
return {
|
||||
...status,
|
||||
report: {
|
||||
isLoggedIn: status.isLoggedIn ? '已登录' : '未登录',
|
||||
monitoring: status.isMonitoring ? '监控中' : '未监控',
|
||||
lastCheck: status.lastCheckTime ?
|
||||
new Date(status.lastCheckTime).toLocaleString() : '从未检查',
|
||||
interval: `${status.checkInterval / 1000}秒`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局实例
|
||||
const globalIMStatusManager = new IMStatusManager()
|
||||
|
||||
// 便捷函数
|
||||
export const startIMMonitoring = (interval) => globalIMStatusManager.startMonitoring(interval)
|
||||
export const stopIMMonitoring = () => globalIMStatusManager.stopMonitoring()
|
||||
export const checkIMStatusNow = () => globalIMStatusManager.forceCheck()
|
||||
export const getIMStatus = () => globalIMStatusManager.getCurrentStatus()
|
||||
export const getIMStatusReport = () => globalIMStatusManager.getStatusReport()
|
||||
|
||||
// 导出管理器实例和类
|
||||
export { globalIMStatusManager, IMStatusManager }
|
||||
export default globalIMStatusManager
|
||||
2816
utils/tim-chat.js
Normal file
2816
utils/tim-chat.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user