Compare commits

...

5 Commits

16 changed files with 1039 additions and 265 deletions

49
App.vue
View File

@ -1,20 +1,55 @@
<script>
import dbStore from '@/store/db';
import dbStore from "@/store/db";
import accountStore from "@/store/account";
export default {
onLaunch: function () {
console.log('App Launch: ')
async onLaunch() {
console.log("App Launch: ");
// openId IM
await this.initIMOnLaunch();
},
onShow: function () {
const db = dbStore();
if(db && typeof db.getJobs === 'function'){
if (db && typeof db.getJobs === "function") {
db.getJobs();
}
},
onHide: function () {
console.log('App Hide')
console.log("App Hide");
},
methods: {
async initIMOnLaunch() {
try {
const account = accountStore();
// openId
const storedAccount = uni.getStorageSync("account");
const storedOpenId = uni.getStorageSync("openid");
if (storedOpenId) {
console.log("检测到已登录的 openId开始初始化 IM:", storedOpenId);
account.openid = storedOpenId;
//
if (storedAccount) {
account.account = storedAccount;
}
}
// IM
const success = await account.initIMAfterLogin();
if (success) {
console.log("IM 初始化成功");
} else {
console.warn("IM 初始化失败");
}
} else {
console.log("未检测到 openId跳过 IM 初始化");
}
} catch (error) {
console.error("App Launch 初始化 IM 失败:", error);
}
},
},
};
</script>
<style lang="scss">
@ -30,7 +65,6 @@ page {
rgba(0, 0, 0, 0.1) 0px -10px 15px -3px, rgba(0, 0, 0, 0.1) 0px -4px 6px -4px;
}
.shadow-lg {
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
}
@ -322,7 +356,6 @@ page {
text-overflow: ellipsis;
}
.w-0 {
width: 0;
}

View File

@ -7,8 +7,14 @@
<view v-if="customScroll" class="page-scroll">
<slot></slot>
</view>
<scroll-view v-else scroll-y="true" :scroll-top="scrollTop" class="page-scroll" @scrolltolower="scrolltolower"
@scroll="onScroll">
<scroll-view
v-else
scroll-y="true"
:scroll-top="scrollTop"
class="page-scroll"
@scrolltolower="scrolltolower"
@scroll="onScroll"
>
<slot></slot>
</scroll-view>
</view>
@ -16,19 +22,19 @@
<slot name="footer"></slot>
</view>
<!-- #ifdef MP-->
<view class="safeareaBottom"></view>
<!-- <view class="safeareaBottom"></view> -->
<!-- #endif -->
</view>
</template>
<script setup>
import { computed, useSlots, ref } from 'vue';
import useDebounce from '@/utils/useDebounce';
import { computed, useSlots, ref } from "vue";
import useDebounce from "@/utils/useDebounce";
const emits = defineEmits(['reachBottom']);
const emits = defineEmits(["reachBottom"]);
const props = defineProps({
customScroll: { type: Boolean, default: false },
mainStyle: { default: '' },
pageStyle: { default: '' }
mainStyle: { default: "" },
pageStyle: { default: "" },
});
const slots = useSlots();
const hasHeader = computed(() => !!slots.header);
@ -37,7 +43,7 @@ const hasFooter = computed(() => !!slots.footer);
const scrollTop = ref(0);
const scrolltolower = useDebounce(() => {
emits('reachBottom');
emits("reachBottom");
});
const onScroll = useDebounce((e) => {
@ -49,9 +55,8 @@ function scrollToBottom() {
}
defineExpose({
scrollToBottom
})
scrollToBottom,
});
</script>
<style lang="scss" scoped>
.full-page {

View File

@ -7,6 +7,18 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/article/article-list",
"style": {
"navigationBarTitleText": "我的宣教"
}
},
{
"path": "pages/survey/survey-list",
"style": {
"navigationBarTitleText": "我的问卷"
}
},
{
"path": "pages/message/message",
"style": {

View File

@ -1,55 +1,209 @@
<template>
<full-page :customScroll="articles.length === 0" @reachBottom="loadMore()">
<view v-if="articles.length === 0" class="flex items-center justify-center h-full">
<empty-data />
<view class="bg-gray-100 min-h-screen">
<!-- Filter Tabs -->
<scroll-view scroll-x class="bg-white whitespace-nowrap px-15 py-10 sticky top-0 z-10 w-full" :show-scrollbar="false">
<view
v-for="(tab, index) in tabs"
:key="index"
class="inline-block px-15 py-5 mr-10 text-sm rounded-full border transition-colors"
:class="[
activeTab === tab.value
? 'bg-orange-100 text-orange-500 border-orange-500'
: 'bg-white text-gray-600 border-gray-200'
]"
@click="activeTab = tab.value"
>
{{ tab.name }}
</view>
<view v-else class="p-15">
<view v-for="article in articles" :key="article._id" class="flex px-15 mb-10 py-12 shadow-lg bg-white rounded">
<image class="flex-shrink-0 mr-10 cover" :src="article.cover || '/static/book.svg'" />
<view class="w-0 flex-grow">
<view class="text-base leading-normal font-semibold truncate mb-5">
{{ article.title }}
</scroll-view>
<!-- Article List -->
<view class="p-15">
<view
v-for="item in filteredArticles"
:key="item._id"
class="bg-white rounded-lg p-15 mb-15 shadow-sm"
@click="goToDetail(item)"
>
<!-- Header -->
<view class="flex items-start justify-between mb-10">
<view class="flex items-start flex-1 mr-10 relative">
<!-- Tag -->
<view class="text-xs text-green-600 border border-green-600 px-5 rounded mr-5 flex-shrink-0 mt-1 tag-box">
{{ item.type }}
</view>
<view v-if="article.summary" class="text-base text-gray line-clamp-2">
{{ article.summary }}
<!-- Title -->
<view class="text-base font-bold text-gray-800 leading-normal line-clamp-2">
{{ item.title }}
</view>
</view>
<!-- Status -->
<view class="flex items-center flex-shrink-0 ml-2">
<text
class="text-sm mr-2"
:class="item.status === 'UNREAD' ? 'text-red-500' : 'text-gray-400'"
>
{{ item.status === 'UNREAD' ? '未阅读' : '查看' }}
</text>
<uni-icons type="right" size="14" :color="item.status === 'UNREAD' ? '#ef4444' : '#9ca3af'"></uni-icons>
</view>
</view>
<!-- Content -->
<!-- Person -->
<view class="text-sm text-gray-600 mb-5 flex items-center">
<text class="text-gray-400 mr-2">人员:</text>
<text>{{ item.person }}</text>
</view>
<!-- Team -->
<view class="text-sm text-gray-600 mb-10 pb-10 border-b border-gray-100 flex items-center">
<text class="text-gray-400 mr-2">团队:</text>
<text>{{ item.team }}</text>
</view>
<!-- Footer -->
<view class="text-sm text-gray-400">
发送时间: {{ item.time }}
</view>
</view>
</view>
</view>
</full-page>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import api from '@/utils/api';
import { ref, computed } from 'vue';
import FullPage from '@/components/full-page.vue';
import EmptyData from '@/components/empty-data.vue';
// Mock Data
const tabs = [
{ name: '全部', value: 'ALL' },
{ name: '李珊珊 女 37岁', value: '1' },
{ name: '罗小小 女 17岁', value: '2' },
];
const corpId = ref('');
const ids = ref('');
const articles = ref([]);
const activeTab = ref('ALL');
onLoad((options) => {
corpId.value = options.corpId;
ids.value = options.ids;
if (ids.value) {
getArticles()
const articles = ref([
{
_id: 1,
type: '宣教文章',
title: '妊糖管理期注意事项!',
status: 'UNREAD',
person: '李珊珊 女 36岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '1'
},
{
_id: 2,
type: '宣教文章',
title: '妊糖管理期注意事项!妊糖管理期注意事项!妊糖管理期注意事项......',
status: 'READ',
person: '李珊珊 女 36岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '1'
},
{
_id: 3,
type: '宣教文章',
title: '妊糖管理期注意事项!',
status: 'READ',
person: '罗小小 女 16岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '2'
}
]);
const filteredArticles = computed(() => {
if (activeTab.value === 'ALL') return articles.value;
return articles.value.filter(item => item.ownerId === activeTab.value);
});
async function getArticles() {
const res = await api('getArticleByIds', { corpId: corpId.value, ids: ids.value });
articles.value = res && Array.isArray(res.list) ? res.list : [];
}
function loadMore() {
console.log('addArchive')
function goToDetail(item) {
console.log('Navigate to detail', item);
}
</script>
<style scoped>
.cover {
width: 128rpx;
height: 128rpx;
/* Utility helpers similar to Windi/Tailwind */
.min-h-screen { min-height: 100vh; }
.bg-gray-100 { background-color: #f7f8fa; }
.bg-white { background-color: #ffffff; }
.p-15 { padding: 30rpx; }
.px-15 { padding-left: 30rpx; padding-right: 30rpx; }
.py-10 { padding-top: 20rpx; padding-bottom: 20rpx; }
.py-5 { padding-top: 10rpx; padding-bottom: 10rpx; }
.px-5 { padding-left: 10rpx; padding-right: 10rpx; }
.mr-10 { margin-right: 20rpx; }
.mr-5 { margin-right: 10rpx; }
.ml-2 { margin-left: 10rpx; }
.mr-2 { margin-right: 10rpx; }
.mb-15 { margin-bottom: 30rpx; }
.mb-10 { margin-bottom: 20rpx; }
.mb-5 { margin-bottom: 10rpx; }
.mt-1 { margin-top: 6rpx; }
.pb-10 { padding-bottom: 20rpx; }
.flex { display: flex; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.flex-shrink-0 { flex-shrink: 0; }
.relative { position: relative; }
.border { border-width: 1px; border-style: solid; }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.rounded-full { border-radius: 9999px; }
.rounded { border-radius: 8rpx; }
.rounded-lg { border-radius: 12rpx; }
.shadow-sm { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.text-xs { font-size: 22rpx; }
.text-sm { font-size: 28rpx; }
.text-base { font-size: 32rpx; }
.font-bold { font-weight: 600; }
.leading-normal { line-height: 1.4; }
/* Colors - Adjusting to match image roughly */
.text-orange-500 { color: #f29e38; }
.bg-orange-100 { background-color: #fff8eb; }
.border-orange-500 { border-color: #f29e38; }
.text-gray-600 { color: #333333; }
.text-gray-500 { color: #999999; }
.text-gray-400 { color: #999999; }
.text-gray-800 { color: #1a1a1a; }
.border-gray-200 { border-color: #e5e5e5; }
.border-gray-100 { border-color: #f5f5f5; }
.text-green-600 { color: #4b8d5f; }
.border-green-600 { border-color: #4b8d5f; }
.text-red-500 { color: #e04a4a; }
.sticky { position: sticky; }
.top-0 { top: 0; }
.z-10 { z-index: 10; }
.w-full { width: 100%; }
.whitespace-nowrap { white-space: nowrap; }
.inline-block { display: inline-block; }
.tag-box {
border-radius: 4rpx;
line-height: 1.2;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
word-break: break-all;
}
</style>

View File

@ -74,7 +74,7 @@ const consultItems = ref([
label: "我的问卷",
icon: "/static/homepage/survey-icon.png",
bgColor: "#58D68D",
path: "/pages/health/list",
path: "/pages/survey/survey-list",
},
{
id: "rating",

View File

@ -286,7 +286,6 @@ defineExpose({
.popup-footer {
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #f0f0f0;
}

View File

@ -4,53 +4,55 @@
</view>
</template>
<script setup>
import { ref } from 'vue';
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import api from '@/utils/api';
import api from "@/utils/api";
import { set } from "@/utils/cache";
import { toast } from '@/utils/widget';
import { toast } from "@/utils/widget";
const teamId = ref('');
const corpId = ref('');
const teamId = ref("");
const corpId = ref("");
const loading = ref(false);
const team = ref(null)
const team = ref(null);
async function changeTeam({ teamId, corpId, corpName }) {
loading.value = true;
const res = await api('getTeamData', { teamId, corpId, withCorpName: true });
const res = await api("getTeamData", { teamId, corpId, withCorpName: true });
loading.value = false;
if (res && res.data) {
team.value = res.data;
team.value.corpName = corpName;
set('invite-team-info', {
set("invite-team-info", {
corpId: team.value.corpId,
teamId: team.value.teamId,
corpName: team.value.corpName,
teamName: team.value.name,
avatars: team.value && Array.isArray(team.value.memberList) ? team.value.memberList.map(item => item.avatar || '') : [],
})
avatars:
team.value && Array.isArray(team.value.memberList)
? team.value.memberList.map((item) => item.avatar || "")
: [],
});
uni.redirectTo({
url: '/pages/login/login?source=teamInvite'
})
url: "/pages/login/login?source=teamInvite",
});
} else {
toast(res?.message || '获取团队信息失败')
toast(res?.message || "获取团队信息失败");
}
}
onLoad(options => {
const href = typeof options.q === 'string' ? decodeURIComponent(options.q) : '';
const [, url = ''] = href.split('?');
const data = url.split('&').reduce((acc, cur) => {
console.log(cur)
const [key, value] = cur.split('=');
console.log(key, '=====', value)
onLoad((options) => {
const href =
typeof options.q === "string" ? decodeURIComponent(options.q) : "";
const [, url = ""] = href.split("?");
const data = url.split("&").reduce((acc, cur) => {
console.log(cur);
const [key, value] = cur.split("=");
console.log(key, "=====", value);
acc[key] = value;
return acc;
}, {})
changeTeam(data)
})
}, {});
changeTeam(data);
});
</script>
<style>
.flash-logo {

View File

@ -348,23 +348,30 @@ $primary-color: #0877F1;
.text-input,
.voice-input-btn {
flex: 1;
padding: 0 46rpx;
padding: 16rpx 46rpx;
background-color: #f3f5fa;
border-radius: 20rpx;
margin: 0 16rpx;
font-size: 28rpx;
height: 80rpx;
min-height: 80rpx;
max-height: 200rpx;
border: none;
outline: none;
box-sizing: border-box;
display: flex;
align-items: center;
line-height: 96rpx;
line-height: 1.5;
color: #333;
}
.voice-input-btn {
height: 80rpx;
display: flex;
align-items: center;
padding: 0 46rpx;
}
.voice-input-btn {
text-align: center;
line-height: 80rpx;
}
.more-panel {

View File

@ -6,8 +6,9 @@
<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" />
<textarea v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
@confirm="sendTextMessage" @focus="handleInputFocus" @input="handleInput"
:auto-height="true" :show-confirm-bar="false" :adjust-position="true" />
<input v-else class="voice-input-btn" :class="{ recording: isRecording }" @touchstart="startRecord"
@touchmove="onRecordTouchMove" @touchend="stopRecord" @touchcancel="cancelRecord" :placeholder="isRecording ? '松开发送' : '按住说话'" disabled>
</input>
@ -334,6 +335,13 @@ function handleInputFocus() {
});
}
function handleInput(e) {
// textarea
nextTick().then(() => {
emit("scrollToBottom");
});
}
onMounted(() => {
//
initRecorderManager();

View File

@ -19,7 +19,7 @@
<!-- 消息列表项 -->
<view
v-for="conversation in conversationList"
:key="conversation.conversationID"
:key="conversation.groupID || conversation.conversationID"
class="message-item"
@click="handleClickConversation(conversation)"
>
@ -38,11 +38,15 @@
<view class="content">
<view class="header">
<text class="name">{{ conversation.name || "未知群聊" }}</text>
<text class="name">{{ conversation.teamName }}</text>
<text class="time">{{
formatMessageTime(conversation.lastMessageTime)
}}</text>
</view>
<view class="patient-info">
咨询人 | {{ conversation.patientName }}
</view>
<view class="message-preview">
<text class="preview-text">{{
conversation.lastMessage || "暂无消息"
@ -76,6 +80,7 @@ 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 { mergeConversationWithGroupDetails } from "@/utils/conversation-merger.js";
//
const { account, openid, isIMInitialized } = storeToRefs(useAccountStore());
@ -90,21 +95,47 @@ const refreshing = ref(false);
// IM
const initIM = async () => {
console.log("=== message.vue initIM 开始 ===");
console.log("isIMInitialized:", isIMInitialized.value);
console.log("globalTimChatManager 存在:", !!globalTimChatManager);
console.log("globalTimChatManager.tim 存在:", !!globalTimChatManager?.tim);
console.log(
"globalTimChatManager.isLoggedIn:",
globalTimChatManager?.isLoggedIn
);
if (!isIMInitialized.value) {
uni.showLoading({
title: "连接中...",
});
console.log("开始调用 initIMAfterLogin");
const success = await initIMAfterLogin();
console.log("initIMAfterLogin 返回:", success);
uni.hideLoading();
if (!success) {
console.error("initIMAfterLogin 失败");
uni.showToast({
title: "IM连接失败请重试",
icon: "none",
});
return false;
}
console.log("initIMAfterLogin 成功后检查:");
console.log("- globalTimChatManager 存在:", !!globalTimChatManager);
console.log(
"- globalTimChatManager.tim 存在:",
!!globalTimChatManager?.tim
);
console.log(
"- globalTimChatManager.isLoggedIn:",
globalTimChatManager?.isLoggedIn
);
} else if (globalTimChatManager && !globalTimChatManager.isLoggedIn) {
console.log("IM 已初始化但未登录,尝试重连");
uni.showLoading({
title: "重连中...",
});
@ -112,9 +143,12 @@ const initIM = async () => {
uni.hideLoading();
if (!reconnected) {
console.error("重连失败");
return false;
}
}
console.log("=== message.vue initIM 完成 ===");
return true;
};
@ -125,33 +159,32 @@ const loadConversationList = async () => {
try {
console.log("开始加载群聊列表");
if (!globalTimChatManager || !globalTimChatManager.getGroupList) {
// IM TIM
if (!globalTimChatManager) {
throw new Error("IM管理器未初始化");
}
if (!globalTimChatManager.tim) {
console.error("TIM实例不存在尝试重新初始化");
const imReady = await initIM();
if (!imReady || !globalTimChatManager.tim) {
throw new Error("TIM实例初始化失败");
}
}
if (!globalTimChatManager.getGroupList) {
throw new Error("getGroupList 方法不存在");
}
// 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,
"个会话"
//
conversationList.value = await mergeConversationWithGroupDetails(
result.groupList
);
console.log("群聊列表加载成功,共", conversationList.value, "个会话");
} else {
console.error("加载群聊列表失败:", result);
uni.showToast({
@ -170,6 +203,9 @@ const loadConversationList = async () => {
}
};
//
let updateTimer = null;
//
const setupConversationListener = () => {
if (!globalTimChatManager) return;
@ -186,7 +222,7 @@ const setupConversationListener = () => {
);
if (existingIndex !== -1) {
//
//
if (eventData.unreadCount !== undefined) {
conversationList.value[existingIndex].unreadCount =
eventData.unreadCount;
@ -204,6 +240,12 @@ const setupConversationListener = () => {
return;
}
//
if (updateTimer) {
clearTimeout(updateTimer);
}
updateTimer = setTimeout(async () => {
//
const groupConversations = eventData.filter(
(conv) => conv.conversationID && conv.conversationID.startsWith("GROUP")
@ -211,34 +253,66 @@ const setupConversationListener = () => {
console.log(`收到 ${groupConversations.length} 个群聊会话更新`);
// - 使 tim-chat.js
groupConversations.forEach((updatedConv) => {
const conversationID = updatedConv.conversationID;
// 使 TimChatManager
const formattedConversations = groupConversations.map((conv) =>
globalTimChatManager.formatConversationData(conv)
);
//
const mergedConversations = await mergeConversationWithGroupDetails(
formattedConversations
);
if (!mergedConversations || mergedConversations.length === 0) {
console.log("合并后的会话数据为空,跳过更新");
return;
}
let needSort = false;
//
mergedConversations.forEach((conversationData) => {
const conversationID = conversationData.conversationID;
const existingIndex = conversationList.value.findIndex(
(conv) => conv.conversationID === conversationID
);
// 使 TimChatManager
const conversationData =
globalTimChatManager.formatConversationData(updatedConv);
if (existingIndex !== -1) {
//
conversationList.value[existingIndex] = conversationData;
const existing = conversationList.value[existingIndex];
//
if (
existing.lastMessage !== conversationData.lastMessage ||
existing.lastMessageTime !== conversationData.lastMessageTime ||
existing.unreadCount !== conversationData.unreadCount
) {
//
conversationList.value[existingIndex] = {
...conversationData,
//
avatar: existing.avatar || conversationData.avatar,
//
unreadCount: Math.max(existing.unreadCount || 0, conversationData.unreadCount || 0)
};
needSort = true;
console.log(
`已更新会话: ${conversationData.name}, unreadCount: ${conversationData.unreadCount}`
`已更新会话: ${conversationData.name}, unreadCount: ${conversationList.value[existingIndex].unreadCount}`
);
}
} else {
//
conversationList.value.push(conversationData);
needSort = true;
console.log(`已添加新会话: ${conversationData.name}`);
}
});
//
//
if (needSort) {
conversationList.value.sort(
(a, b) => b.lastMessageTime - a.lastMessageTime
);
}
}, 100); // 100ms
});
//
@ -254,19 +328,23 @@ const setupConversationListener = () => {
if (conversationIndex !== -1) {
const conversation = conversationList.value[conversationIndex];
//
//
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
const isViewingConversation =
currentPage?.route === "pages/message/index";
//
if (isViewingConversation) {
// groupID
const currentGroupID = currentPage?.options?.groupID;
const isViewingThisConversation =
currentPage?.route === "pages/message/index" &&
currentGroupID === conversation.groupID;
//
if (isViewingThisConversation) {
console.log("用户正在查看该会话,不增加未读数");
return;
}
//
//
conversation.unreadCount = (conversation.unreadCount || 0) + 1;
console.log(
"已更新会话未读数:",
@ -369,22 +447,19 @@ onLoad(() => {
//
onShow(async () => {
try {
console.log("消息列表页面显示,开始初始化");
// IM
const imReady = await initIM();
if (!imReady) {
console.error("IM初始化失败");
return;
}
if (!imReady) initIMAfterLogin();
//
await loadConversationList();
//
setupConversationListener();
} catch (error) {
console.error("页面初始化失败:", error);
uni.showToast({
title: "初始化失败,请重试",
title: error.message || "初始化失败,请重试",
icon: "none",
});
}
@ -392,6 +467,12 @@ onShow(async () => {
//
onHide(() => {
//
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = null;
}
//
if (globalTimChatManager) {
globalTimChatManager.setCallback("onConversationListUpdated", null);
@ -518,8 +599,8 @@ onHide(() => {
}
.preview-text {
font-size: 28rpx;
color: #999;
font-size: 26rpx;
// color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@ -534,4 +615,10 @@ onHide(() => {
font-size: 24rpx;
color: #999;
}
.patient-info {
font-size: 26rpx;
color: #999;
padding-bottom: 10rpx;
}
</style>

View File

@ -0,0 +1,210 @@
<template>
<view class="bg-gray-100 min-h-screen">
<!-- Filter Tabs -->
<scroll-view scroll-x class="bg-white whitespace-nowrap px-15 py-10 sticky top-0 z-10 w-full" :show-scrollbar="false">
<view
v-for="(tab, index) in tabs"
:key="index"
class="inline-block px-15 py-5 mr-10 text-sm rounded-full border transition-colors"
:class="[
activeTab === tab.value
? 'bg-orange-100 text-orange-500 border-orange-500'
: 'bg-white text-gray-600 border-gray-200'
]"
@click="activeTab = tab.value"
>
{{ tab.name }}
</view>
</scroll-view>
<!-- Survey List -->
<view class="p-15">
<view
v-for="item in filteredSurveys"
:key="item._id"
class="bg-white rounded-lg p-15 mb-15 shadow-sm"
@click="goToDetail(item)"
>
<!-- Header -->
<view class="flex items-start justify-between mb-10">
<view class="flex items-start flex-1 mr-10 relative">
<!-- Tag -->
<view class="text-xs text-green-600 border border-green-600 px-5 rounded mr-5 flex-shrink-0 mt-1 tag-box">
{{ item.type }}
</view>
<!-- Title -->
<view class="text-base font-bold text-gray-800 leading-normal line-clamp-2">
{{ item.title }}
</view>
</view>
<!-- Status -->
<view class="flex items-center flex-shrink-0 ml-2">
<text
class="text-sm mr-2"
:class="item.status === 'UNFILLED' ? 'text-red-500' : 'text-gray-400'"
>
{{ item.status === 'UNFILLED' ? '未填写' : '查看' }}
</text>
<uni-icons type="right" size="14" :color="item.status === 'UNFILLED' ? '#ef4444' : '#9ca3af'"></uni-icons>
</view>
</view>
<!-- Content -->
<!-- Person -->
<view class="text-sm text-gray-600 mb-5 flex items-center">
<text class="text-gray-400 mr-2">人员:</text>
<text>{{ item.person }}</text>
</view>
<!-- Team -->
<view class="text-sm text-gray-600 mb-10 pb-10 border-b border-gray-100 flex items-center">
<text class="text-gray-400 mr-2">团队:</text>
<text>{{ item.team }}</text>
</view>
<!-- Footer -->
<view class="text-sm text-gray-400">
发送时间: {{ item.time }}
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue';
// Mock Data
const tabs = [
{ name: '全部', value: 'ALL' },
{ name: '李珊珊 女 37岁', value: '1' },
{ name: '罗小小 女 17岁', value: '2' },
];
const activeTab = ref('ALL');
const surveys = ref([
{
_id: 1,
type: '问卷调查',
title: '门诊满意度调查问卷',
status: 'UNFILLED',
person: '李珊珊 女 36岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '1'
},
{
_id: 2,
type: '问卷调查',
title: 'xxxx术后1周随访调查问卷',
status: 'FILLED',
person: '李珊珊 女 36岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '1'
},
{
_id: 3,
type: '问卷调查',
title: 'xxxxx术后2周随访调查问卷',
status: 'FILLED',
person: '罗小小 女 16岁',
team: '妊糖管理服务团队',
time: '2026-01-09 15:23',
ownerId: '2'
}
]);
const filteredSurveys = computed(() => {
if (activeTab.value === 'ALL') return surveys.value;
return surveys.value.filter(item => item.ownerId === activeTab.value);
});
function goToDetail(item) {
console.log('Navigate to detail', item);
// Add actual navigation logic here later
}
</script>
<style scoped>
/* Utility helpers similar to Windi/Tailwind */
.min-h-screen { min-height: 100vh; }
.bg-gray-100 { background-color: #f7f8fa; }
.bg-white { background-color: #ffffff; }
.p-15 { padding: 30rpx; }
.px-15 { padding-left: 30rpx; padding-right: 30rpx; }
.py-10 { padding-top: 20rpx; padding-bottom: 20rpx; }
.py-5 { padding-top: 10rpx; padding-bottom: 10rpx; }
.px-5 { padding-left: 10rpx; padding-right: 10rpx; }
.mr-10 { margin-right: 20rpx; }
.mr-5 { margin-right: 10rpx; }
.ml-2 { margin-left: 10rpx; }
.mr-2 { margin-right: 10rpx; }
.mb-15 { margin-bottom: 30rpx; }
.mb-10 { margin-bottom: 20rpx; }
.mb-5 { margin-bottom: 10rpx; }
.mt-1 { margin-top: 6rpx; }
.pb-10 { padding-bottom: 20rpx; }
.flex { display: flex; }
.items-start { align-items: flex-start; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-1 { flex: 1; }
.flex-shrink-0 { flex-shrink: 0; }
.relative { position: relative; }
.border { border-width: 1px; border-style: solid; }
.border-b { border-bottom-width: 1px; border-bottom-style: solid; }
.rounded-full { border-radius: 9999px; }
.rounded { border-radius: 8rpx; }
.rounded-lg { border-radius: 12rpx; }
.shadow-sm { box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.text-xs { font-size: 22rpx; }
.text-sm { font-size: 28rpx; }
.text-base { font-size: 32rpx; }
.font-bold { font-weight: 600; }
.leading-normal { line-height: 1.4; }
/* Colors - Adjusting to match image roughly */
.text-orange-500 { color: #f29e38; }
.bg-orange-100 { background-color: #fff8eb; }
.border-orange-500 { border-color: #f29e38; }
.text-gray-600 { color: #333333; }
.text-gray-500 { color: #999999; }
.text-gray-400 { color: #999999; }
.text-gray-800 { color: #1a1a1a; }
.border-gray-200 { border-color: #e5e5e5; }
.border-gray-100 { border-color: #f5f5f5; }
.text-green-600 { color: #4b8d5f; }
.border-green-600 { border-color: #4b8d5f; }
.text-red-500 { color: #e04a4a; }
.sticky { position: sticky; }
.top-0 { top: 0; }
.z-10 { z-index: 10; }
.w-full { width: 100%; }
.whitespace-nowrap { white-space: nowrap; }
.inline-block { display: inline-block; }
.tag-box {
border-radius: 4rpx;
line-height: 1.2;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
word-break: break-all;
}
</style>

BIN
static/icon/zhaopian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -32,6 +32,11 @@ export default defineStore("accountStore", () => {
if (res.success && res.data && res.data.mobile) {
account.value = res.data;
openid.value = res.data.openid;
// 保存账户信息和 openId 到本地存储
uni.setStorageSync('account', res.data);
uni.setStorageSync('openid', res.data.openid);
initIMAfterLogin(openid.value)
return res.data
}
@ -48,8 +53,29 @@ export default defineStore("accountStore", () => {
return true;
}
try {
await initGlobalTIM();
// 使用 openid 作为 userID 初始化 IM
const userID = openid.value || uni.getStorageSync('openid');
if (!userID) {
console.error('无法获取 openidIM 初始化失败');
return false;
}
console.log('开始初始化 IMuserID:', userID);
const success = await initGlobalTIM(userID);
if (!success) {
console.error('initGlobalTIM 返回失败');
return false;
}
// 验证 TIM 实例是否真正创建成功
if (!globalTimChatManager || !globalTimChatManager.tim) {
console.error('IM 初始化后 TIM 实例不存在');
return false;
}
isIMInitialized.value = true;
console.log('IM 初始化成功');
return true;
} catch (error) {
console.error('IM初始化失败:', error);
@ -73,6 +99,10 @@ export default defineStore("accountStore", () => {
account.value = null;
openid.value = "";
isIMInitialized.value = false;
// 清除本地存储
uni.removeStorageSync('account');
uni.removeStorageSync('openid');
}
return { account, login, initIMAfterLogin, logout, openid, isIMInitialized }

View File

@ -57,7 +57,8 @@ const urlsConfig = {
endConsultation: "endConsultation",
getGroupListByGroupId: "getGroupListByGroupId",
createConsultGroup: "createConsultGroup",
cancelConsultApplication: "cancelConsultApplication"
cancelConsultApplication: "cancelConsultApplication",
getGroupList: "getGroupList"
}
}
const urls = Object.keys(urlsConfig).reduce((acc, path) => {

View File

@ -0,0 +1,199 @@
/**
* 会话列表与群组详细信息合并工具
*
* 功能
* 1. conversationList 中提取所有 groupID
* 2. 调用后端 getGroupList 接口获取群组详细信息
* 3. 合并会话数据和患者信息
* 4. 过滤掉后端不存在的会话
*/
import api from "@/utils/api.js"
/**
* 合并会话列表和群组详细信息
*
* @param {Array} conversationList - 前端会话列表
* @param {Object} options - 可选参数
* @param {string} options.corpId - 企业ID可选
* @param {string} options.teamId - 团队ID可选
* @param {string} options.keyword - 搜索关键词可选
* @returns {Promise<Array>} 合并后的会话列表
*/
export async function mergeConversationWithGroupDetails(conversationList, options = {}) {
try {
// 1. 参数校验
if (!Array.isArray(conversationList) || conversationList.length === 0) {
console.log('会话列表为空,无需合并')
return []
}
// 2. 提取所有 groupID
const groupIds = conversationList
.map(conv => conv.groupID)
.filter(id => id) // 过滤掉空值
if (groupIds.length === 0) {
console.log('没有有效的 groupID无需合并')
return []
}
console.log('提取到的 groupID 列表:', groupIds)
// 3. 调用后端接口获取群组详细信息
const requestData = {
groupIds,
...options // 支持传入额外的查询参数corpId, teamId, keyword等
}
const response = await api('getGroupList', requestData)
// 4. 检查响应
if (!response || !response.success) {
console.error('获取群组详细信息失败:', response?.message || '未知错误')
return []
}
const groupDetailsMap = createGroupDetailsMap(response.data?.list || [])
console.log('获取到的群组详细信息数量:', Object.keys(groupDetailsMap).size)
// 5. 合并数据并过滤
const mergedList = conversationList
.map(conversation => mergeConversationData(conversation, groupDetailsMap))
.filter(item => item !== null) // 过滤掉后端不存在的会话
console.log('合并后的会话列表数量:', mergedList.length)
console.log('过滤掉的会话数量:', conversationList.length - mergedList.length)
// 6. 格式化并排序会话列表
const formattedList = mergedList.sort((a, b) => b.lastMessageTime - a.lastMessageTime)
return formattedList
} catch (error) {
console.error('合并会话列表失败:', error)
// 发生错误时返回空数组,避免影响页面渲染
return []
}
}
/**
* 创建群组详细信息映射表
*
* @param {Array} groupDetailsList - 后端返回的群组详细信息列表
* @returns {Map} groupID -> 群组详细信息的映射
*/
function createGroupDetailsMap(groupDetailsList) {
const map = new Map()
groupDetailsList.forEach(groupDetail => {
if (groupDetail.groupId) {
map.set(groupDetail.groupId, groupDetail)
}
})
return map
}
/**
* 合并单个会话数据
*
* @param {Object} conversation - 前端会话数据
* @param {Map} groupDetailsMap - 群组详细信息映射表
* @returns {Object|null} 合并后的会话数据如果后端不存在则返回 null
*/
function mergeConversationData(conversation, groupDetailsMap) {
const groupDetail = groupDetailsMap.get(conversation.groupID)
// 如果后端没有该群组信息,返回 null会被过滤掉
if (!groupDetail) {
console.log(`会话 ${conversation.groupID} 在后端不存在,已过滤`)
return null
}
// 合并数据
return {
// 保留原有的会话信息
...conversation,
// 合并后端的群组信息
_id: groupDetail._id,
corpId: groupDetail.corpId,
teamId: groupDetail.teamId,
customerId: groupDetail.customerId,
doctorId: groupDetail.doctorId,
patientId: groupDetail.patientId,
orderStatus: groupDetail.orderStatus,
// 合并患者信息(优先使用后端数据)
patientName: groupDetail.patientName || conversation.patientName,
patientSex: groupDetail.patient?.sex,
patientAge: groupDetail.patient?.age,
patientMobile: groupDetail.patient?.mobile,
patientAvatar: groupDetail.patient?.avatar || conversation.avatar,
// 合并团队信息
teamName: groupDetail.team?.name,
teamMemberList: groupDetail.team?.memberList,
teamDescription: groupDetail.team?.description,
// 时间信息
createdAt: groupDetail.createdAt,
updatedAt: groupDetail.updatedAt,
// 更新显示名称(使用后端的患者信息)
name: formatConversationName(groupDetail),
// 更新头像(优先使用已有头像,避免闪动)
avatar: conversation.avatar || groupDetail.patient?.avatar || '/static/default-avatar.png'
}
}
/**
* 格式化会话显示名称
*
* @param {Object} groupDetail - 群组详细信息
* @returns {string} 格式化后的名称
*/
function formatConversationName(groupDetail) {
const patientName = groupDetail.patientName || '患者'
const sex = groupDetail.patient?.sex === 1 ? '男' : groupDetail.patient?.sex === 2 ? '女' : ''
const age = groupDetail.patient?.age ? `${groupDetail.patient.age}` : ''
// 拼接名称:患者名 性别 年龄
const nameParts = [patientName, sex, age].filter(part => part)
const displayName = nameParts.join(' ')
return `${displayName}的问诊`
}
/**
* 批量合并会话列表支持分页
*
* @param {Array} conversationList - 前端会话列表
* @param {Object} options - 可选参数
* @param {number} options.batchSize - 每批处理的数量默认50
* @returns {Promise<Array>} 合并后的会话列表
*/
export async function mergeConversationWithGroupDetailsBatch(conversationList, options = {}) {
const { batchSize = 50, ...otherOptions } = options
if (!Array.isArray(conversationList) || conversationList.length === 0) {
return []
}
// 分批处理
const batches = []
for (let i = 0; i < conversationList.length; i += batchSize) {
batches.push(conversationList.slice(i, i + batchSize))
}
console.log(`会话列表分为 ${batches.length} 批处理,每批 ${batchSize}`)
// 并发处理所有批次
const results = await Promise.all(
batches.map(batch => mergeConversationWithGroupDetails(batch, otherOptions))
)
// 合并所有结果
return results.flat()
}

View File

@ -149,6 +149,7 @@ class TimChatManager {
this.isInitializing = true
console.log('=== 开始初始化IM ===')
console.log('传入的 userID:', userID)
try {
// 重置重连次数,允许重新登录
@ -170,9 +171,12 @@ class TimChatManager {
console.log('创建TIM实例SDKAppID:', TIM_CONFIG.SDKAppID)
this.tim = TIM.create({ SDKAppID: TIM_CONFIG.SDKAppID })
console.log('TIM实例创建成功:', !!this.tim)
// 等待TIM实例初始化完成
console.log('等待TIM实例就绪...')
await this.waitForTIMInstanceReady()
console.log('TIM实例已就绪')
// 注册上传插件
this.tim.registerPlugin({ "tim-upload-plugin": TIMUploadPlugin })
@ -180,6 +184,7 @@ class TimChatManager {
// 注册事件监听器
this.registerEventListeners()
console.log('事件监听器已注册')
// 设置日志级别
if (typeof this.tim.setLogLevel === 'function') {
@ -187,21 +192,29 @@ class TimChatManager {
}
// 获取用户信息并登录
console.log('开始获取用户信息并登录...')
await this.getUserInfoAndLogin(userID)
console.log('用户信息获取并登录成功')
// 等待SDK Ready
console.log('等待SDK Ready...')
await this.waitForSDKReady(IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT)
console.log('=== IM初始化完成 ===')
console.log('最终状态 - this.tim 存在:', !!this.tim)
console.log('最终状态 - this.isLoggedIn:', this.isLoggedIn)
return true
} catch (error) {
console.error('=== IM初始化失败 ===', error)
console.error('错误详情:', error.message || error)
console.error('错误堆栈:', error.stack)
this.triggerCallback('onError', `初始化失败: ${error.message || error}`)
// 初始化失败时清理资源
console.log('初始化失败,开始清理资源...')
await this.cleanupOldInstance()
console.log('资源清理完成this.tim 已设为 null')
return false
} finally {
this.isInitializing = false
@ -365,20 +378,34 @@ class TimChatManager {
async getUserInfoAndLogin(userID) {
try {
console.log('getUserInfoAndLogin 开始,传入 userID:', userID)
if (userID) {
this.currentUserID = userID
uni.setStorageSync('userInfo', { userID })
console.log('使用传入的 userID:', userID)
} else {
console.log('未传入 userID尝试从本地存储获取')
const userInfo = uni.getStorageSync('userInfo')
console.log('本地存储的 userInfo:', userInfo)
if (!userInfo?.userID) {
throw new Error('未找到用户信息,请先登录')
}
this.currentUserID = userInfo.userID
console.log('从本地存储获取到 userID:', this.currentUserID)
}
console.log('开始获取 userSig...')
this.currentUserSig = await this.getUserSig(this.currentUserID)
console.log('userSig 获取成功')
console.log('开始登录 TIM...')
await this.loginTIM()
console.log('TIM 登录成功')
} catch (error) {
console.error('获取用户信息失败:', error)
console.error('错误详情:', error.message || error)
this.triggerCallback('onError', `登录失败: ${error.message || error}`)
throw error // 重新抛出错误,让调用者知道登录失败
}