This commit is contained in:
huxuejian 2026-01-27 17:12:40 +08:00
commit b6bc5a24c7
19 changed files with 3010 additions and 357 deletions

25
App.vue
View File

@ -29,13 +29,36 @@ export default {
</script>
<style lang="scss">
$primary-color: #0074ff;
$primary-color: #0877F1;
page {
height: 100%;
background: #f5f5f5;
}
/* 全局按钮样式 - 使用项目主题色 */
button[type="primary"],
.button-primary,
uni-button[type="primary"] {
background-color: $primary-color !important;
border-color: $primary-color !important;
color: #fff !important;
}
button[type="primary"]:not([disabled]):active,
.button-primary:active,
uni-button[type="primary"]:not([disabled]):active {
background-color: darken($primary-color, 10%) !important;
border-color: darken($primary-color, 10%) !important;
}
/* 微信小程序按钮样式覆盖 */
.wx-button[type="primary"] {
background-color: $primary-color !important;
border-color: $primary-color !important;
color: #fff !important;
}
.shadow-up {
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px,
rgba(0, 0, 0, 0.1) 0px -10px 15px -3px, rgba(0, 0, 0, 0.1) 0px -4px 6px -4px;

View File

@ -6,6 +6,36 @@
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/message/common-phrases",
"style": {
"navigationBarTitleText": "常用语"
}
},
{
"path": "pages/message/article-list",
"style": {
"navigationBarTitleText": "宣教文章"
}
},
{
"path": "pages/message/article-detail",
"style": {
"navigationBarTitleText": "宣教文章"
}
},
{
"path": "pages/message/survey-list",
"style": {
"navigationBarTitleText": "问卷列表"
}
},
{
"path": "pages/webview/webview",
"style": {
"navigationBarTitleText": "预览"
}
},
{
"path": "pages/message/index",
"style": {

View File

@ -1,95 +0,0 @@
# 常用语功能说明
## 功能概述
在聊天页面添加了常用语功能,医生可以快速选择并发送预设的常用语内容。
## 功能特性
### 1. 常用语列表页面 (`common-phrases.vue`)
- 支持分类管理(文字随访、语音随访、常用回复等)
- 支持添加、编辑、删除常用语
- 支持添加自定义分类
- 点击常用语直接发送到聊天
### 2. 聊天输入框集成
- 在聊天输入框的"更多"面板中添加"常用语"入口
- 点击后跳转到常用语列表页面
- 选择常用语后自动返回并发送
### 3. 数据持久化
- 常用语数据存储在MongoDB的`common-words`集合中
- 支持机构级别和个人级别的常用语
- 与PC端管理后台数据互通
## 文件结构
```
ykt-wx-app/pages/message/
├── common-phrases.vue # 常用语列表页面
├── components/
│ └── chat-input.vue # 聊天输入框组件(已更新)
├── index.vue # 聊天主页面(已更新)
└── README.md # 本文档
ykt-wx-app/utils/
└── api.js # API配置已添加常用语接口
ytk-customer-service/knowledgeBase/common-words/
└── index.js # 后端常用语接口(已添加小程序接口)
```
## API接口
### 1. 获取常用语列表
```javascript
api('getCommonPhrases', { corpId })
```
### 2. 保存常用语
```javascript
api('saveCommonPhrase', {
id, // 可选,更新时传入
corpId,
userId,
categoryId,
content
})
```
### 3. 删除常用语
```javascript
api('deleteCommonPhrase', {
id,
corpId
})
```
### 4. 获取分类列表
```javascript
api('getCommonPhraseCategories', { corpId })
```
### 5. 保存分类
```javascript
api('saveCommonPhraseCategory', {
corpId,
userId,
name
})
```
## 使用流程
1. 用户在聊天页面点击输入框右侧的"+"按钮
2. 在弹出的功能面板中点击"常用语"
3. 跳转到常用语列表页面
4. 可以切换分类查看不同类型的常用语
5. 点击任意常用语,自动返回聊天页面并发送该内容
6. 在编辑模式下可以添加、编辑、删除常用语
## 注意事项
1. 常用语功能需要用户已登录并有有效的corpId
2. 如果后端API调用失败会使用本地模拟数据
3. 常用语内容最多500字
4. 分类名称最多6个字

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>

View File

@ -136,7 +136,7 @@ $primary-color: #0877F1;
.message-list {
padding: 0 16rpx;
padding-bottom: 20rpx;
padding-bottom: 60rpx; /* 增加底部内边距,防止被小程序底部横线遮挡 */
}
.message-item {
@ -350,10 +350,10 @@ $primary-color: #0877F1;
flex: 1;
padding: 0 46rpx;
background-color: #f3f5fa;
border-radius: 50rpx;
border-radius: 20rpx;
margin: 0 16rpx;
font-size: 28rpx;
height: 96rpx;
height: 80rpx;
border: none;
outline: none;
box-sizing: border-box;
@ -372,8 +372,8 @@ $primary-color: #0877F1;
justify-content: flex-start;
background: #fff;
border-top: 1rpx solid #eee;
padding: 20rpx 0 8rpx 60rpx;
gap: 40rpx;
padding: 20rpx 0 40rpx 60rpx;
gap: 40rpx 50rpx;
flex-wrap: wrap;
background-color: #f5f5f5;
}
@ -1248,4 +1248,113 @@ $primary-color: #0877F1;
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10rpx); }
}
}
/* 文章卡片样式 */
.article-card {
display: flex;
align-items: center;
background-color: transparent;
border-radius: 12rpx;
padding: 24rpx;
max-width: 500rpx;
box-shadow: none;
background-color: #fff;
}
.article-content {
flex: 1;
display: flex;
flex-direction: column;
margin-right: 20rpx;
min-width: 0;
}
.article-title {
font-size: 28rpx;
color: #333;
font-weight: 500;
line-height: 1.4;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.article-desc {
font-size: 24rpx;
color: #999;
line-height: 1.3;
}
.article-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
/* 文章卡片在不同消息流中的样式 */
.message-right .article-card {
background-color: transparent;
}
.message-left .article-card {
background-color: transparent;
}
/* 问卷卡片样式 */
.survey-card {
display: flex;
align-items: center;
background-color: #fff;
border-radius: 12rpx;
padding: 24rpx;
max-width: 500rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
}
.survey-content {
flex: 1;
display: flex;
flex-direction: column;
margin-right: 20rpx;
min-width: 0;
}
.survey-title {
font-size: 28rpx;
color: #333;
font-weight: 500;
line-height: 1.4;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.survey-desc {
font-size: 24rpx;
color: #999;
line-height: 1.3;
}
.survey-image {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
flex-shrink: 0;
}
/* 问卷卡片在不同消息流中的样式 */
.message-right .survey-card {
background-color: #e8f4ff;
}
.message-left .survey-card {
background-color: #fff;
}

View File

@ -5,11 +5,11 @@
<view class="category-sidebar">
<scroll-view class="category-list" scroll-y>
<view
v-for="category in categories"
v-for="category in allCategories"
:key="category.id"
class="category-item"
:class="{ active: currentCategory === category.id }"
@click="switchCategory(category.id)"
@click="handleCategoryClick(category)"
@longpress="handleCategoryLongPress(category)"
>
<view class="category-content">
@ -23,26 +23,16 @@
</view>
</view>
</view>
<view class="add-category-item" @click="showAddCategoryDialog">
<text class="plus-icon">+</text>
</view>
</scroll-view>
<!-- 新增分类按钮固定在底部 -->
<view class="add-category-footer" @click="showAddCategoryDialog">
<text class="add-category-text">新增分类</text>
</view>
</view>
<!-- 右侧常用语列表 -->
<view class="phrases-container">
<!-- 顶部操作栏 -->
<view class="action-bar">
<view class="add-phrase-btn" @click="showAddPhraseDialog">
<text class="add-icon">+</text>
<text class="add-text">添加快捷回复</text>
</view>
<view class="edit-btn" @click="toggleEditMode">
<text class="edit-text">{{ isEditMode ? "完成" : "编辑" }}</text>
</view>
</view>
<scroll-view class="phrases-list" scroll-y>
<view
v-for="phrase in currentPhrases"
@ -66,9 +56,20 @@
<view v-if="currentPhrases.length === 0" class="empty-state">
<text class="empty-text">暂无常用语</text>
<text class="empty-hint">点击方按钮添加常用语</text>
<text class="empty-hint">点击方按钮添加常用语</text>
</view>
</scroll-view>
<!-- 底部操作栏页脚 -->
<view class="footer-action-bar">
<view class="add-phrase-btn" @click="showAddPhraseDialog">
<text class="add-icon">+</text>
<text class="add-text">添加快捷回复</text>
</view>
<view class="edit-btn" @click="toggleEditMode">
<text class="edit-text">{{ isEditMode ? "完成" : "编辑" }}</text>
</view>
</view>
</view>
</view>
@ -108,7 +109,9 @@
>
<view class="popup-content category-popup" @click.stop>
<view class="popup-header">
<text class="popup-title">新建分类</text>
<text class="popup-title">{{
editingCategory ? "编辑分类" : "新建分类"
}}</text>
<view class="popup-close" @click="closeCategoryPopup">
<text class="close-icon">×</text>
</view>
@ -117,11 +120,12 @@
v-model="categoryForm.name"
class="category-input"
placeholder="请输入分类名最多6个字"
maxlength="6"
/>
<view class="popup-actions">
<button class="cancel-btn" @click="closeCategoryPopup">取消</button>
<button class="confirm-btn" @click="saveCategory">确认添加</button>
<button class="confirm-btn" @click="saveCategory">
{{ editingCategory ? "保存" : "确认添加" }}
</button>
</view>
</view>
</view>
@ -130,7 +134,12 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { storeToRefs } from "pinia";
import api from "@/utils/api";
import useAccountStore from "@/store/account";
// store
const { doctorInfo } = storeToRefs(useAccountStore());
//
const statusBarHeight = ref(0);
@ -138,21 +147,64 @@ const statusBarHeight = ref(0);
//
const isEditMode = ref(false);
//
const categories = ref([
{ id: "visit", name: "文字随访", deletable: false },
{ id: "voice", name: "语音随访", deletable: false },
{ id: "common", name: "常用回复", deletable: false },
]);
//
const DEFAULT_CATEGORY = {
id: "__default__",
name: "默认",
sort: -1,
deletable: false,
type: "personal",
};
const currentCategory = ref("common");
//
const categories = ref([]);
//
const corpCategories = ref([]);
// + +
const allCategories = computed(() => {
//
const personalCats = [DEFAULT_CATEGORY, ...categories.value];
//
const corpCats = corpCategories.value.map(cat => ({
...cat,
type: "corp",
deletable: false,
isCorpCategory: true, //
}));
return [...personalCats, ...corpCats];
});
const currentCategory = ref(DEFAULT_CATEGORY.id);
//
const phrases = ref([]);
//
const corpPhrases = ref([]);
//
const currentPhrases = computed(() => {
return phrases.value.filter((p) => p.categoryId === currentCategory.value);
//
const currentCat = allCategories.value.find(c => c.id === currentCategory.value);
if (currentCategory.value === DEFAULT_CATEGORY.id) {
// categoryId categoryId
return phrases.value.filter((p) => !p.categoryId);
}
//
if (currentCat?.isCorpCategory) {
return corpPhrases.value.filter((p) => p.categoryId === currentCategory.value);
}
//
const filtered = phrases.value.filter((p) => p.categoryId === currentCategory.value);
console.log("当前分类:", currentCategory.value);
return filtered;
});
//
@ -169,6 +221,7 @@ const categoryForm = ref({
});
const editingPhrase = ref(null);
const editingCategory = ref(null);
//
const goBack = () => {
@ -185,6 +238,24 @@ const switchCategory = (categoryId) => {
currentCategory.value = categoryId;
};
//
const handleCategoryClick = (category) => {
if (isEditMode.value && category.deletable) {
//
editCategory(category);
} else {
//
switchCategory(category.id);
}
};
//
const editCategory = (category) => {
editingCategory.value = category;
categoryForm.value.name = category.name;
showCategoryPopup.value = true;
};
//
const handlePhraseClick = (phrase) => {
if (isEditMode.value) {
@ -220,63 +291,79 @@ const editPhrase = (phrase) => {
const savePhrase = async () => {
if (!phraseForm.value.content.trim()) {
uni.showToast({
title: '请输入内容',
icon: 'none'
title: "请输入内容",
icon: "none",
});
return;
}
try {
const corpId = uni.getStorageSync('corpId');
const userId = uni.getStorageSync('userId');
if (!corpId || !userId) {
if (!doctorInfo.value) {
uni.showToast({
title: '请先登录',
icon: 'none'
title: "请先登录",
icon: "none",
});
return;
}
const result = await api('saveCommonPhrase', {
const corpId = doctorInfo.value.corpId;
const userId = doctorInfo.value.userid;
// categoryId null
let categoryId = editingPhrase.value?.categoryId || currentCategory.value;
if (categoryId === DEFAULT_CATEGORY.id) {
categoryId = null;
}
const result = await api("savePersonalPhrase", {
id: editingPhrase.value?.id,
categoryId: editingPhrase.value?.categoryId || currentCategory.value,
categoryId: categoryId,
content: phraseForm.value.content,
corpId,
userId
userId,
});
if (result.code === 200) {
console.log("保存常用语返回结果:", result);
if (result.success) {
if (editingPhrase.value) {
//
const index = phrases.value.findIndex(p => p.id === editingPhrase.value.id);
const index = phrases.value.findIndex(
(p) => p.id === editingPhrase.value.id
);
if (index !== -1) {
phrases.value[index].content = phraseForm.value.content;
// categoryId null
const newCategoryId = editingPhrase.value.categoryId || currentCategory.value;
phrases.value[index].categoryId = newCategoryId === DEFAULT_CATEGORY.id ? null : newCategoryId;
}
} else {
//
console.log("新增常用语,返回数据:", result.data);
console.log("当前分类ID:", currentCategory.value);
if (result.data) {
phrases.value.push(result.data);
console.log("添加后的 phrases:", phrases.value);
}
}
uni.showToast({
title: editingPhrase.value ? '保存成功' : '添加成功',
icon: 'success'
title: editingPhrase.value ? "保存成功" : "添加成功",
icon: "success",
});
closePopup();
} else {
uni.showToast({
title: result.message || '操作失败',
icon: 'none'
title: result.message || "操作失败",
icon: "none",
});
}
} catch (error) {
console.error('保存常用语失败:', error);
console.error("保存常用语失败:", error);
uni.showToast({
title: '操作失败',
icon: 'none'
title: "操作失败",
icon: "none",
});
}
};
@ -289,20 +376,31 @@ const deletePhrase = (phrase) => {
success: async (res) => {
if (res.confirm) {
try {
await api("deleteCommonPhrase", {
const corpId = doctorInfo.value.corpId;
const userId = doctorInfo.value.userid;
const result = await api("deletePersonalPhrase", {
id: phrase.id,
corpId: uni.getStorageSync("corpId"),
corpId,
userId,
});
const index = phrases.value.findIndex((p) => p.id === phrase.id);
if (index !== -1) {
phrases.value.splice(index, 1);
if (result.success) {
const index = phrases.value.findIndex((p) => p.id === phrase.id);
if (index !== -1) {
phrases.value.splice(index, 1);
}
uni.showToast({
title: "删除成功",
icon: "success",
});
} else {
uni.showToast({
title: result.message || "删除失败",
icon: "none",
});
}
uni.showToast({
title: "删除成功",
icon: "success",
});
} catch (error) {
console.error("删除常用语失败:", error);
uni.showToast({
@ -317,12 +415,13 @@ const deletePhrase = (phrase) => {
//
const showAddCategoryDialog = () => {
editingCategory.value = null;
categoryForm.value.name = "";
showCategoryPopup.value = true;
};
//
const saveCategory = () => {
const saveCategory = async () => {
if (!categoryForm.value.name.trim()) {
uni.showToast({
title: "请输入分类名",
@ -331,18 +430,66 @@ const saveCategory = () => {
return;
}
categories.value.push({
id: `category_${Date.now()}`,
name: categoryForm.value.name,
deletable: true, //
});
if (categoryForm.value.name.length > 6) {
uni.showToast({
title: "输入内容超过6个字",
icon: "none",
});
return;
}
uni.showToast({
title: "添加成功",
icon: "success",
});
try {
const corpId = doctorInfo.value.corpId;
const userId = doctorInfo.value.userid;
closeCategoryPopup();
const result = await api("savePersonalPhraseCategory", {
id: editingCategory.value?.id, // id
name: categoryForm.value.name,
corpId,
userId,
});
if (result.success && result.data) {
if (editingCategory.value) {
//
const index = categories.value.findIndex(
(c) => c.id === editingCategory.value.id
);
if (index !== -1) {
categories.value[index].name = result.data.name;
}
uni.showToast({
title: "修改成功",
icon: "success",
});
} else {
//
categories.value.push({
id: result.data.id,
name: result.data.name,
sort: result.data.sort,
deletable: result.data.deletable,
});
uni.showToast({
title: "添加成功",
icon: "success",
});
}
closeCategoryPopup();
} else {
uni.showToast({
title: result.message || "操作失败",
icon: "none",
});
}
} catch (error) {
console.error("保存分类失败:", error);
uni.showToast({
title: "操作失败",
icon: "none",
});
}
};
//
@ -361,33 +508,54 @@ const handleCategoryLongPress = (category) => {
};
//
const deleteCategory = (category) => {
const deleteCategory = async (category) => {
if (!category.deletable) {
uni.showToast({
title: "默认分类不可删除",
title: "分类不可删除",
icon: "none",
});
return;
}
//
phrases.value = phrases.value.filter((p) => p.categoryId !== category.id);
try {
const corpId = doctorInfo.value.corpId;
const userId = doctorInfo.value.userid;
//
const index = categories.value.findIndex((c) => c.id === category.id);
if (index !== -1) {
categories.value.splice(index, 1);
const result = await api("deletePersonalPhraseCategory", {
id: category.id,
corpId,
userId,
});
if (result.success) {
//
const index = categories.value.findIndex((c) => c.id === category.id);
if (index !== -1) {
categories.value.splice(index, 1);
}
//
if (currentCategory.value === category.id) {
currentCategory.value = DEFAULT_CATEGORY.id;
}
uni.showToast({
title: "删除成功",
icon: "success",
});
} else {
uni.showToast({
title: result.message || "删除失败",
icon: "none",
});
}
} catch (error) {
console.error("删除分类失败:", error);
uni.showToast({
title: "删除失败",
icon: "none",
});
}
//
if (currentCategory.value === category.id && categories.value.length > 0) {
currentCategory.value = categories.value[0].id;
}
uni.showToast({
title: "删除成功",
icon: "success",
});
};
//
@ -402,104 +570,46 @@ const closeCategoryPopup = () => {
//
const loadPhrases = async () => {
try {
const corpId = uni.getStorageSync('corpId');
const userId = uni.getStorageSync('userId');
if (!corpId) {
uni.showToast({
title: '请先登录',
icon: 'none'
});
return;
}
const result = await api('getCommonPhrases', {
const corpId = doctorInfo.value.corpId;
const userId = doctorInfo.value.userid;
const result = await api("getPersonalPhrases", {
corpId,
userId
userId,
});
if (result.code === 200) {
if (result.success && result.data) {
//
if (Array.isArray(result.data)) {
phrases.value = result.data;
if (Array.isArray(result.data.phrases)) {
phrases.value = result.data.phrases;
}
//
if (Array.isArray(result.categories)) {
const backendCategories = result.categories.map(cat => ({
id: cat.id,
name: cat.name,
deletable: true //
}));
// 使使
if (backendCategories.length > 0) {
categories.value = backendCategories;
//
if (!currentCategory.value || !categories.value.find(c => c.id === currentCategory.value)) {
currentCategory.value = categories.value[0].id;
//
if (Array.isArray(result.data.categories)) {
categories.value = result.data.categories;
//
if (currentCategory.value && currentCategory.value !== DEFAULT_CATEGORY.id) {
const categoryExists = categories.value.find((c) => c.id === currentCategory.value);
if (!categoryExists) {
currentCategory.value = DEFAULT_CATEGORY.id;
}
}
}
} else {
uni.showToast({
title: result.message || "加载失败",
icon: "none",
});
}
} catch (error) {
console.error('加载常用语失败:', error);
console.error("加载常用语失败:", error);
uni.showToast({
title: '加载失败',
icon: 'none'
title: "加载失败",
icon: "none",
});
}
};
//
const getMockPhrases = () => {
return [
{
id: 1,
categoryId: "common",
content:
"想要买草药自己熬还是配方颗粒直接开水冲着喝线上购买全国包邮一二线城市一般1-2天送到其他城市可能慢一些。",
},
{
id: 2,
categoryId: "common",
content: "以前得过什么病吗?做过什么检查吗?有检查结果吗?详细说一下。",
},
{
id: 3,
categoryId: "common",
content: "把我之前给您开的处方,拍个照上传给我看一下,拍招清楚一点。",
},
{
id: 4,
categoryId: "common",
content: "服药期间要慎炸身体,少吃油炸等辛辣食物,按时作息,精神放松。",
},
{
id: 5,
categoryId: "common",
content:
"煎药最好用砂锅避免用铁锅煎药前用冷水浸泡30分钟水量超过药材2-5厘米一般中药煎2次第1次煮沸后再煎20分钟就可以倒出来了再次加水煮沸后再煎30分钟后倒出将2次倒出的药混合在一起就可以按照医嘱用药了。",
},
{
id: 6,
categoryId: "common",
content:
"如果在线上买药,点击整体治疗方案,选择首选处方,划价取药,填写地址后去付款即可。",
},
{
id: 7,
categoryId: "voice",
content: "想要买草药自己熬还是配方颗粒直接开水冲着喝?",
},
{
id: 8,
categoryId: "visit",
content: "您好,我是您的健康管理师,请问有什么可以帮助您的吗?",
},
];
};
onMounted(() => {
//
const systemInfo = uni.getSystemInfoSync();
@ -511,7 +621,7 @@ onMounted(() => {
<style scoped lang="scss">
// 使
$primary-color: #0877f1;
$primary-color: #0877F1;
$primary-light: #e8f3ff;
$primary-gradient-start: #1b5cc8;
$primary-gradient-end: #0877f1;
@ -600,34 +710,29 @@ $primary-gradient-end: #0877f1;
}
}
//
.action-bar {
//
.footer-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin: 24rpx 24rpx 0;
.edit-btn {
padding: 16rpx 32rpx;
.edit-text {
font-size: 28rpx;
color: $primary-color;
font-weight: 500;
}
}
padding: 24rpx;
background-color: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.04);
.add-phrase-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 16rpx 24rpx;
padding: 20rpx 24rpx;
background: linear-gradient(
135deg,
$primary-gradient-end 0%,
$primary-gradient-start 100%
);
border-radius: 40rpx;
border-radius: 30rpx;
box-shadow: 0 4rpx 16rpx rgba(8, 119, 241, 0.2);
.add-icon {
@ -643,6 +748,19 @@ $primary-gradient-end: #0877f1;
font-weight: 500;
}
}
.edit-btn {
padding: 20rpx 32rpx;
// background-color: $primary-light;
border-radius: 20rpx;
// box-shadow: 0 2rpx 8rpx rgba(8, 119, 241, 0.15);
.edit-text {
font-size: 28rpx;
color: $primary-color;
font-weight: 500;
}
}
}
.content-wrapper {
@ -653,17 +771,21 @@ $primary-gradient-end: #0877f1;
//
.category-sidebar {
width: 180rpx;
width: 210rpx;
background-color: #fff;
border-right: 1px solid #f0f0f0;
display: flex;
flex-direction: column;
.category-list {
height: 100%;
flex: 1;
height: 0; // flex: 1
overflow-y: auto; //
}
.category-item {
position: relative;
padding: 40rpx 16rpx;
padding: 20rpx 16rpx;
text-align: center;
background-color: #fff;
transition: all 0.3s;
@ -690,10 +812,10 @@ $primary-gradient-end: #0877f1;
.delete-badge {
position: absolute;
top: -12rpx;
right: 8rpx;
width: 32rpx;
height: 32rpx;
top: -20rpx;
right: 2rpx;
width: 28rpx;
height: 28rpx;
background-color: #ff3b30;
border-radius: 50%;
display: flex;
@ -702,7 +824,7 @@ $primary-gradient-end: #0877f1;
box-shadow: 0 2rpx 8rpx rgba(255, 59, 48, 0.3);
.delete-icon {
font-size: 28rpx;
font-size: 24rpx;
color: #fff;
line-height: 1;
font-weight: 300;
@ -710,27 +832,25 @@ $primary-gradient-end: #0877f1;
}
}
.add-category-item {
//
.add-category-footer {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 16rpx;
font-size: 24rpx;
color: $primary-color;
background-color: #fff;
border-top: 1px solid #f0f0f0;
box-shadow: 0 -2rpx 8rpx rgba(0, 0, 0, 0.04);
margin-bottom: 18rpx;
.plus-icon {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 40rpx;
font-weight: 300;
.add-category-text {
font-size: 26rpx;
color: $primary-color;
font-weight: 500;
}
&:active {
background-color: $primary-light;
border-radius: 50%;
}
}
}
@ -745,7 +865,7 @@ $primary-gradient-end: #0877f1;
.phrases-list {
flex: 1;
padding: 24rpx;
padding: 10rpx;
height: 0; // flex: 1
overflow-y: auto; //
@ -894,7 +1014,7 @@ $primary-gradient-end: #0877f1;
.category-input {
width: 100%;
padding: 20rpx;
padding: 0 20rpx; // padding padding
font-size: 28rpx;
color: #333;
background-color: #f8f9fa;
@ -903,6 +1023,7 @@ $primary-gradient-end: #0877f1;
margin-bottom: 32rpx;
box-sizing: border-box;
height: 100rpx;
line-height: 100rpx; //
}
.popup-actions {

View File

@ -73,10 +73,13 @@ const props = defineProps({
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"]);
const emit = defineEmits(["messageSent", "scrollToBottom", "endConsult"]);
//
const inputText = ref("");
@ -361,6 +364,38 @@ const goToCommonPhrases = () => {
});
};
//
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 },
{
@ -376,17 +411,17 @@ const morePanelButtons = [
{
text: "宣教",
icon: "/static/icon/xuanjiaowenzhang.png",
action: showImagePicker,
action: goToArticleList,
},
{
text: "问卷",
icon: "/static/icon/zhaopian.png",
action: showImagePicker,
icon: "/static/icon/wenjuan.png",
action: goToSurveyList,
},
{
text: "结束问诊",
icon: "/static/icon/jieshuzixun.png",
action: showImagePicker,
action: handleEndConsult,
},
];

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

@ -63,8 +63,46 @@
</view>
<!-- 自定义消息卡片 -->
<!-- <template v-else-if="message.type === 'TIMCustomElem'">
<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)"
>
@ -74,8 +112,8 @@
:flow="message.flow"
@viewDetail="$emit('viewDetail', $event)"
/>
</view>
</template> -->
</view> -->
</template>
</template>
<script setup>
@ -163,6 +201,80 @@ const getVoiceStyle = (duration) => {
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">

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

@ -1,9 +1,9 @@
<template>
<view v-if="notifyText" class="notify-bar">
<!-- <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> -->
<view class="system-message">
<view class="system-text">{{ text }}</view>
</view>
@ -21,17 +21,111 @@ const props = defineProps({
const payload = computed(() => props.message?.payload || {});
const extension = computed(() => {
//
const systemMessageData = computed(() => {
try {
return JSON.parse(payload.value.extension);
// 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) {
return {};
console.error('解析系统消息失败:', e);
}
return null;
});
const text = computed(() => extension.value.patient || payload.value.data || '')
//
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 notifyText = computed(() => extension.value.notifyText || '')
//
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>

View File

@ -54,7 +54,9 @@
<image
v-if="message.flow === 'in'"
class="doctor-msg-avatar"
:src="chatMember[message.from]?.avatar || '/static/default-avatar.png'"
:src="
chatMember[message.from]?.avatar || '/static/default-avatar.png'
"
mode="aspectFill"
/>
@ -62,7 +64,9 @@
<image
v-if="message.flow === 'out'"
class="user-msg-avatar"
:src="chatMember[message.from]?.avatar || '/static/home/avatar.svg'"
:src="
chatMember[message.from]?.avatar || '/static/home/avatar.svg'
"
mode="aspectFill"
/>
@ -81,7 +85,7 @@
}}</text>
</view>
<!-- 消息气泡 -->
<view class="message-bubble" :class="getBubbleClass(message)">
<!-- 消息内容 -->
<MessageTypes
@ -108,14 +112,32 @@
</view>
</scroll-view>
<!-- 接受问诊组件 -->
<ConsultAccept
v-if="showConsultAccept"
@accept="handleAcceptConsult"
@reject="handleRejectConsult"
/>
<!-- 拒绝原因对话框 -->
<RejectReasonModal
:visible="showRejectReasonModal"
@confirm="handleRejectReasonConfirm"
@cancel="handleRejectReasonCancel"
/>
<!-- 聊天输入组件 -->
<ChatInput
v-if="!isEvaluationPopupOpen"
v-if="!isEvaluationPopupOpen && !showConsultAccept"
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>
@ -143,13 +165,20 @@ import {
handleViewDetail,
checkIMConnectionStatus,
} from "@/utils/chat-utils.js";
import { sendConsultRejectedMessage, endConsultation, sendArticleMessage } 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 ConsultAccept from "./components/consult-accept.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();
@ -158,15 +187,12 @@ const { initIMAfterLogin } = useAccountStore();
const chatInputRef = ref(null);
const groupId = ref("");
const {
chatMember,
getGroupInfo
} = useGroupChat(groupId);
const { chatMember, getGroupInfo } = useGroupChat(groupId);
//
const updateNavigationTitle = () => {
uni.setNavigationBarTitle({
title: "群聊"
title: "群聊",
});
};
@ -180,6 +206,12 @@ const chatInfo = ref({
//
const isEvaluationPopupOpen = ref(false);
//
const showConsultAccept = ref(false);
//
const showRejectReasonModal = ref(false);
//
const messageList = ref([]);
const isLoading = ref(false);
@ -192,10 +224,80 @@ const lastFirstMessageId = ref("");
//
function isSystemMessage(message) {
const description = message.payload?.description;
return (
message.type === "TIMCustomElem" && description === "SYSTEM_NOTIFICATION"
);
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"
) {
showConsultAccept.value = true;
return;
}
//
if (
data.type === "system_message" &&
(data.messageType === "consult_accepted" ||
data.messageType === "consult_ended" ||
data.messageType === "consult_rejected")
) {
showConsultAccept.value = false;
return;
}
} catch (error) {
console.error("解析系统消息失败:", error);
}
}
}
//
showConsultAccept.value = false;
}
//
@ -204,9 +306,8 @@ function getBubbleClass(message) {
if (message.type === "TIMImageElem") {
return "image-bubble";
}
if (message.type === "TIMCustomElem") {
return message.flow === "out" ? "user-bubble" : "doctor-bubble-blue";
return message.flow === "out" ? "" : "";
}
return message.flow === "out" ? "user-bubble" : "doctor-bubble";
}
@ -224,6 +325,7 @@ onLoad((options) => {
if (options.userID) {
chatInfo.value.userID = options.userID;
}
checkLoginAndInitTIM();
updateNavigationTitle();
});
@ -288,6 +390,10 @@ const initTIMCallbacks = async () => {
if (!existingMessage) {
messageList.value.push(message);
console.log("✓ 添加消息到列表,当前消息数量:", messageList.value.length);
//
checkConsultPendingStatus();
// 使
nextTick(() => {
scrollToBottom(true);
@ -346,6 +452,9 @@ const initTIMCallbacks = async () => {
isCompleted.value = data.isCompleted || false;
isLoadingMore.value = false;
//
checkConsultPendingStatus();
nextTick(() => {
if (data.isRefresh) {
console.log("后台刷新完成,保持当前滚动位置");
@ -485,16 +594,16 @@ 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 = '';
scrollIntoView.value = "";
nextTick(() => {
scrollIntoView.value = targetId;
});
} else {
// 使DOM
scrollIntoView.value = '';
scrollIntoView.value = "";
setTimeout(() => {
scrollIntoView.value = targetId;
}, 50);
@ -594,18 +703,152 @@ onHide(() => {
stopIMMonitoring();
});
//
const sendCommonPhrase = (content) => {
if (chatInputRef.value) {
chatInputRef.value.sendTextMessageFromPhrase(content);
}
};
//
defineExpose({
sendCommonPhrase
sendCommonPhrase,
});
//
const handleAcceptConsult = async () => {
try {
uni.showLoading({
title: "处理中...",
});
//
const customMessage = {
data: JSON.stringify({
type: "system_message",
messageType: "consult_accepted",
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) {
showConsultAccept.value = false;
uni.hideLoading();
uni.showToast({
title: "已接受问诊",
icon: "success",
});
} else {
throw new Error(sendResult.message || "发送失败");
}
} catch (error) {
console.error("接受问诊失败:", error);
uni.hideLoading();
uni.showToast({
title: error.message || "操作失败",
icon: "none",
});
}
};
//
const handleRejectConsult = () => {
//
showRejectReasonModal.value = true;
};
//
const handleRejectReasonConfirm = async (reason) => {
try {
showRejectReasonModal.value = false;
uni.showLoading({
title: "处理中...",
});
//
const memberName = account.value?.name || "医生";
// ID
const groupId = chatInfo.value.conversationID.replace("GROUP", "");
//
const result = await sendConsultRejectedMessage({
groupId,
memberName,
reason,
});
uni.hideLoading();
if (result.success) {
showConsultAccept.value = false;
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",
});
}
};
//
const handleRejectReasonCancel = () => {
showRejectReasonModal.value = false;
};
//
const handleEndConsult = async () => {
try {
uni.showLoading({
title: "处理中...",
});
// groupId
const result = await 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();
@ -615,6 +858,101 @@ onUnmounted(() => {
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>

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>

24
pages/webview/webview.vue Normal file
View File

@ -0,0 +1,24 @@
<template>
<view class="webview-container">
<web-view :src="url"></web-view>
</view>
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { ref } from "vue";
const url = ref("");
onLoad((options) => {
if (options.url) {
url.value = decodeURIComponent(options.url);
}
});
</script>
<style scoped>
.webview-container {
width: 100%;
height: 100vh;
}
</style>

58
styles/theme.scss Normal file
View File

@ -0,0 +1,58 @@
/**
* 项目主题色配置
* 统一管理项目中使用的主题色
*/
// 主题色
$primary-color: #0877F1;
$primary-light: #e8f3ff;
$primary-dark: #0660c9;
$primary-gradient-start: #1b5cc8;
$primary-gradient-end: #0877F1;
// 辅助色
$success-color: #4cd964;
$warning-color: #f0ad4e;
$error-color: #dd524d;
$info-color: #909399;
// 文字颜色
$text-color-primary: #333;
$text-color-regular: #666;
$text-color-secondary: #999;
$text-color-placeholder: #c0c4cc;
$text-color-white: #fff;
// 背景颜色
$bg-color-white: #ffffff;
$bg-color-page: #f5f5f5;
$bg-color-hover: #f1f1f1;
$bg-color-mask: rgba(0, 0, 0, 0.4);
// 边框颜色
$border-color-base: #e4e7ed;
$border-color-light: #ebeef5;
$border-color-lighter: #f2f6fc;
// 圆角
$border-radius-small: 4rpx;
$border-radius-base: 8rpx;
$border-radius-large: 16rpx;
$border-radius-round: 999rpx;
// 间距
$spacing-small: 16rpx;
$spacing-base: 24rpx;
$spacing-large: 32rpx;
// 字体大小
$font-size-small: 24rpx;
$font-size-base: 28rpx;
$font-size-medium: 32rpx;
$font-size-large: 36rpx;
$font-size-xlarge: 40rpx;
// 阴影
$box-shadow-light: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
$box-shadow-base: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
$box-shadow-dark: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);

View File

@ -15,7 +15,7 @@
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-primary: #0877F1;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;

View File

@ -26,7 +26,26 @@ const urlsConfig = {
saveCommonPhrase: 'saveCommonPhrase',
deleteCommonPhrase: 'deleteCommonPhrase',
getCommonPhraseCategories: 'getCommonPhraseCategories',
saveCommonPhraseCategory: 'saveCommonPhraseCategory'
saveCommonPhraseCategory: 'saveCommonPhraseCategory',
// 个人常用语接口
getPersonalPhrases: 'getPersonalPhrases',
savePersonalPhrase: 'savePersonalPhrase',
deletePersonalPhrase: 'deletePersonalPhrase',
getPersonalPhraseCategories: 'getPersonalPhraseCategories',
savePersonalPhraseCategory: 'savePersonalPhraseCategory',
deletePersonalPhraseCategory: 'deletePersonalPhraseCategory',
// 宣教文章接口
getArticleCateList: 'getArticleCateList',
getArticleList: 'getArticleList',
getArticle: 'getArticle',
addArticleSendRecord: 'addArticleSendRecord'
},
survery: {
getSurveyCateList: 'getSurveryCateList',
getSurveyList: 'getList',
createSurveyRecord: 'createRecord',
getSurveyDetail: 'getDetail'
},
member: {
addCustomer: 'add',
@ -43,7 +62,10 @@ const urlsConfig = {
im: {
getUserSig: 'getUserSig',
sendSystemMessage: "sendSystemMessage",
getChatRecordsByGroupId: "getChatRecordsByGroupId"
getChatRecordsByGroupId: "getChatRecordsByGroupId",
sendConsultRejectedMessage: "sendConsultRejectedMessage",
endConsultation: "endConsultation",
getGroupListByGroupId: "getGroupListByGroupId"
}
}
@ -77,3 +99,76 @@ export default async function api(urlId, data) {
})
}
// 宣教文章相关 API
export async function getArticleCateList(data) {
return api('getArticleCateList', data);
}
export async function getArticleList(data) {
return api('getArticleList', data);
}
export async function getArticle(data) {
return api('getArticle', data);
}
export async function addArticleSendRecord(data) {
return api('addArticleSendRecord', data);
}
// 问卷相关 API
export async function getSurveyCateList(data) {
return api('getSurveyCateList', data);
}
export async function getSurveyList(data) {
return api('getSurveyList', data);
}
export async function createSurveyRecord(data) {
return api('createSurveyRecord', data);
}
export async function getSurveyDetail(data) {
return api('getSurveyDetail', data);
}
// IM 系统消息相关 API
export async function sendConsultRejectedMessage(data) {
return request({
url: '/getYoucanData/im',
data: {
type: 'sendConsultRejectedMessage',
...data
}
});
}
// 发送宣教文章消息
export async function sendArticleMessage(data) {
return request({
url: '/getYoucanData/im',
data: {
type: 'sendArticleMessage',
...data
}
});
}
// 结束问诊接口
export async function endConsultation(data) {
return request({
url: '/getYoucanData/im',
data: {
type: 'endConsultation',
...data
}
});
}
// 根据群组ID获取群组记录
export async function getGroupListByGroupId(data) {
return api('getGroupListByGroupId', data);
}

View File

@ -363,7 +363,6 @@ class TimChatManager {
}
}
// 获取用户信息并登录
async getUserInfoAndLogin(userID) {
try {
if (userID) {
@ -376,7 +375,6 @@ class TimChatManager {
}
this.currentUserID = userInfo.userID
}
this.currentUserSig = await this.getUserSig(this.currentUserID)
await this.loginTIM()
} catch (error) {
@ -711,26 +709,37 @@ class TimChatManager {
// 获取消息所属的会话ID
const messageConversationID = convertedMessage.conversationID
// 检查是否为系统消息
const isSystemMsg = this.isSystemMessage(convertedMessage)
console.log('收到新消息:', {
messageID: convertedMessage.ID,
messageConversationID: messageConversationID,
currentConversationID: this.currentConversationID,
messageType: convertedMessage.type,
from: convertedMessage.from
from: convertedMessage.from,
isSystemMessage: isSystemMsg
})
// 判断是否为当前会话的消息必须有currentConversationID且匹配才显示
const isCurrentConversation = this.currentConversationID &&
messageConversationID === this.currentConversationID
// 判断是否为当前会话的消息
// 系统消息只要会话ID匹配就显示不要求必须有currentConversationID
// 普通消息必须有currentConversationID且匹配才显示
const isCurrentConversation = isSystemMsg
? messageConversationID === this.currentConversationID
: (this.currentConversationID && messageConversationID === this.currentConversationID)
console.log('消息会话匹配检查:', {
isCurrentConversation,
isSystemMessage: isSystemMsg,
hasCurrentConversationID: !!this.currentConversationID,
conversationIDMatch: messageConversationID === this.currentConversationID
})
if (isCurrentConversation) {
// 当前会话的消息,触发回调
console.log('✓ 消息属于当前会话,触发显示')
this.triggerCallback('onMessageReceived', convertedMessage)
// 处理已读状态
// 处理已读状态(系统消息也标记为已读)
if (this.currentConversationID) {
this.markConversationAsRead(this.currentConversationID)
}
@ -1093,7 +1102,52 @@ class TimChatManager {
} else if (lastMessage.type === 'TIMSoundElem') {
lastMessageText = '[语音]'
} else if (lastMessage.type === 'TIMCustomElem') {
lastMessageText = lastMessage.payload.data || '[自定义消息]'
// 解析自定义消息
try {
const customData = JSON.parse(lastMessage.payload.data)
const messageType = customData.messageType
// 根据消息类型返回不同的预览文本
switch (messageType) {
case 'system_message':
lastMessageText = '[系统消息]'
break
case 'symptom':
lastMessageText = '[病情描述]'
break
case 'prescription':
lastMessageText = '[处方单]'
break
case 'refill':
lastMessageText = '[续方申请]'
break
case 'survey':
lastMessageText = '[问卷调查]'
break
case 'article':
lastMessageText = '[文章]'
break
case "consult_pending":
lastMessageText = '患者向团队发起咨询请在1小时内接诊超时将自动关闭会话'
break
case "consult_rejected":
lastMessageText = '患者向团队发起咨询,由于有紧急事务要处理暂时无法接受咨询.本次会话丿关闭'
break
case "consult_timeout":
lastMessageText = '患者向团队发起咨询,团队成员均未接受咨询,本次会话已自动关闭'
break
case "consult_accepted":
lastMessageText = '已接诊,会话已开始'
break
case "consult_ended":
lastMessageText = '已结束当前会话'
break
default:
lastMessageText = '[自定义消息]'
}
} catch (error) {
console.error('解析自定义消息失败:', error)
lastMessageText = '[自定义消息]'
}
} else {
lastMessageText = '[未知消息类型]'
}
@ -2362,6 +2416,41 @@ class TimChatManager {
}
// 工具方法
// 判断是否为系统消息
isSystemMessage(message) {
if (message.type !== 'TIMCustomElem') {
return false
}
// 检查 payload.data 是否包含系统消息标记
try {
if (message.payload && 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 && message.payload.description === '系统消息标记') {
return true
}
// 兼容旧的系统消息格式
if (message.payload && message.payload.description === 'SYSTEM_NOTIFICATION') {
return true
}
} catch (error) {
console.error('判断系统消息失败:', error)
}
return false
}
filterMessage(message) {
if (message.type === 'TIMCustomElem' && message.payload && message.payload.data) {
if (message.payload.data === 'group_create' || message.payload.data === 'purchased') {