Im 相关修改

This commit is contained in:
wangdongbo 2026-01-28 13:38:05 +08:00
parent 2cc2b01477
commit c66514e5b3
32 changed files with 11310 additions and 16 deletions

View File

@ -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

View File

@ -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
View File

@ -0,0 +1,16 @@
{ // launch.json configurations app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
// launchtypelocalremote, localremote
"version": "0.0",
"configurations": [{
"default" :
{
"launchtype" : "local"
},
"mp-weixin" :
{
"launchtype" : "local"
},
"type" : "uniCloud"
}
]
}

14
package-lock.json generated
View File

@ -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=="
} }
} }
} }

View File

@ -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": {
@ -20,7 +22,7 @@
"UNI_PLATFORM": "mp-weixin" "UNI_PLATFORM": "mp-weixin"
} }
}, },
"localhost": { "localhost": {
"title": "本地", "title": "本地",
"env": { "env": {
"UNI_PLATFORM": "mp-weixin" "UNI_PLATFORM": "mp-weixin"

View File

@ -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
View 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>

View File

@ -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 || '获取档案失败');
} }

View File

@ -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 });

View 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>

View 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>

View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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">{{ '&#xe61a;' }}</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>

View 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>

View 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>

View 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>

View 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;
// 100rpx400rpx
const minWidth = 100;
const maxWidth = 400;
// 15rpx
// 1100rpx2115rpx3130rpx ... 60400rpx
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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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>

View File

@ -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管理器未初始化");
}
// getGroupListSDK
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>

View 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>

View File

@ -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 }
}) })

View File

@ -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
View 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最大值 60000010 分钟)
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
View 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

File diff suppressed because it is too large Load Diff