Im 相关修改
This commit is contained in:
parent
2cc2b01477
commit
c66514e5b3
@ -1,3 +1,4 @@
|
|||||||
MP_API_BASE_URL=http://localhost:8080
|
MP_API_BASE_URL=http://localhost:8080
|
||||||
MP_CACHE_PREFIX=development
|
MP_CACHE_PREFIX=development
|
||||||
MP_WX_APP_ID=wx93af55767423938e
|
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_API_BASE_URL=http://192.168.60.2:8080
|
||||||
MP_CACHE_PREFIX=development
|
MP_CACHE_PREFIX=development
|
||||||
MP_WX_APP_ID=wx93af55767423938e
|
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",
|
"version": "1.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.10"
|
"dayjs": "^1.11.10",
|
||||||
|
"tim-upload-plugin": "^1.4.2",
|
||||||
|
"tim-wx-sdk": "^2.27.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {}
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
@ -17,6 +19,16 @@
|
|||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="
|
"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": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dayjs": "^1.11.10"
|
"dayjs": "^1.11.10",
|
||||||
|
"tim-upload-plugin": "^1.4.2",
|
||||||
|
"tim-wx-sdk": "^2.27.6"
|
||||||
},
|
},
|
||||||
"uni-app": {
|
"uni-app": {
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
13
pages.json
13
pages.json
@ -7,6 +7,19 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/message/message",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "消息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/message/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "聊天",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/login/login",
|
"path": "pages/login/login",
|
||||||
"style": {
|
"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 { account } = storeToRefs(useAccount());
|
||||||
const current = ref(null);
|
const current = ref(null);
|
||||||
const customers = ref([]);
|
const customers = ref([]);
|
||||||
@ -144,6 +146,9 @@ async function getCustomers() {
|
|||||||
customers.value = res && Array.isArray(res.data) ? res.data : [];
|
customers.value = res && Array.isArray(res.data) ? res.data : [];
|
||||||
const customer = customers.value.find(i => current.value && i._id === current.value._id);
|
const customer = customers.value.find(i => current.value && i._id === current.value._id);
|
||||||
current.value = customer || customers.value[0] || null;
|
current.value = customer || customers.value[0] || null;
|
||||||
|
|
||||||
|
// 向父组件传递 customers 数据
|
||||||
|
emit('update:customers', customers.value);
|
||||||
} else {
|
} else {
|
||||||
toast(res.message || '获取档案失败');
|
toast(res.message || '获取档案失败');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@
|
|||||||
<team-head :team="team" :teams="teams" @changeTeam="changeTeam" />
|
<team-head :team="team" :teams="teams" @changeTeam="changeTeam" />
|
||||||
<view class="pb-10"></view>
|
<view class="pb-10"></view>
|
||||||
</template>
|
</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" />
|
<team-mate :team="team" />
|
||||||
<article-list :team="team" />
|
<article-list :team="team" />
|
||||||
</full-page>
|
</full-page>
|
||||||
@ -21,6 +22,7 @@ import { toast } from '@/utils/widget';
|
|||||||
|
|
||||||
import FullPage from '@/components/full-page.vue';
|
import FullPage from '@/components/full-page.vue';
|
||||||
import articleList from './article-list.vue';
|
import articleList from './article-list.vue';
|
||||||
|
import consult from './consult.vue';
|
||||||
import customerArchive from './customer-archive.vue';
|
import customerArchive from './customer-archive.vue';
|
||||||
import teamHead from './team-head.vue';
|
import teamHead from './team-head.vue';
|
||||||
import teamMate from './team-mate.vue';
|
import teamMate from './team-mate.vue';
|
||||||
@ -32,10 +34,15 @@ const { account } = storeToRefs(useAccount());
|
|||||||
|
|
||||||
const team = ref(null);
|
const team = ref(null);
|
||||||
const teams = ref([]);
|
const teams = ref([]);
|
||||||
const loading = ref(true)
|
const loading = ref(true);
|
||||||
|
const customers = ref([]);
|
||||||
|
|
||||||
const corpId = computed(() => team.value?.corpId);
|
const corpId = computed(() => team.value?.corpId);
|
||||||
|
|
||||||
|
function handleCustomersUpdate(newCustomers) {
|
||||||
|
customers.value = newCustomers;
|
||||||
|
}
|
||||||
|
|
||||||
async function changeTeam({ teamId, corpId, corpName }) {
|
async function changeTeam({ teamId, corpId, corpName }) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const res = await api('getTeamData', { teamId, corpId });
|
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>
|
<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>
|
</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>
|
</script>
|
||||||
|
|
||||||
<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>
|
</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 { defineStore } from "pinia";
|
||||||
import api from '@/utils/api';
|
import api from '@/utils/api';
|
||||||
import { toast } from '@/utils/widget';
|
import { toast } from '@/utils/widget';
|
||||||
|
import { initGlobalTIM, globalTimChatManager } from "@/utils/tim-chat.js";
|
||||||
const env = __VITE_ENV__;
|
const env = __VITE_ENV__;
|
||||||
|
|
||||||
|
|
||||||
@ -9,8 +10,10 @@ export default defineStore("accountStore", () => {
|
|||||||
const appid = env.MP_WX_APP_ID;
|
const appid = env.MP_WX_APP_ID;
|
||||||
const account = ref(null);
|
const account = ref(null);
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const isIMInitialized = ref(false);
|
||||||
|
const openid = ref("");
|
||||||
async function login(phoneCode = '') {
|
async function login(phoneCode = '') {
|
||||||
|
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
@ -28,6 +31,8 @@ export default defineStore("accountStore", () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
if (res.success && res.data && res.data.mobile) {
|
if (res.success && res.data && res.data.mobile) {
|
||||||
account.value = res.data;
|
account.value = res.data;
|
||||||
|
openid.value = res.data.openid;
|
||||||
|
initIMAfterLogin(openid.value)
|
||||||
return res.data
|
return res.data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,5 +43,37 @@ export default defineStore("accountStore", () => {
|
|||||||
loading.value = false
|
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 }
|
||||||
})
|
})
|
||||||
28
utils/api.js
28
utils/api.js
@ -14,7 +14,23 @@ const urlsConfig = {
|
|||||||
|
|
||||||
knowledgeBase: {
|
knowledgeBase: {
|
||||||
getArticleByIds: 'getArticleByIds',
|
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: {
|
member: {
|
||||||
addCustomer: 'add',
|
addCustomer: 'add',
|
||||||
@ -32,6 +48,15 @@ const urlsConfig = {
|
|||||||
},
|
},
|
||||||
wecom: {
|
wecom: {
|
||||||
addContactWay: 'addContactWay'
|
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) => {
|
const urls = Object.keys(urlsConfig).reduce((acc, path) => {
|
||||||
@ -62,3 +87,4 @@ export default async function api(urlId, data = {}, loading = true) {
|
|||||||
}
|
}
|
||||||
}, loading)
|
}, 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