This commit is contained in:
zhu
2026-03-27 17:55:18 +08:00
parent ee03132cee
commit 382f6b9811
34 changed files with 1406 additions and 376 deletions

View File

@@ -1 +0,0 @@
{"containers":[],"config":{}}

1
app.js
View File

@@ -1,4 +1,3 @@
// app.js
App({
towxml: require('/towxml/index'),
globalData: {

View File

@@ -3,10 +3,9 @@
"pages/system/welcome/index",
"pages/home/index",
"pages/test/index",
"pages/joinFlow/access/index",
"pages/joinFlow/manual/index",
"pages/joinFlow/person/index",
"pages/expert/index"
"pages/expert/index",
"pages/chat/index"
],
"tabBar": {
"custom": true,
@@ -14,6 +13,9 @@
{
"pagePath": "pages/home/index"
},
{
"pagePath": "pages/chat/index"
},
{
"pagePath": "pages/expert/index"
}
@@ -65,6 +67,7 @@
"t-cascader": "tdesign-miniprogram/cascader/cascader",
"t-form": "tdesign-miniprogram/form/form",
"t-form-item": "tdesign-miniprogram/form-item/form-item",
"t-divider": "tdesign-miniprogram/divider/divider"
"t-divider": "tdesign-miniprogram/divider/divider",
"t-loading": "tdesign-miniprogram/loading/loading"
}
}

View File

@@ -15,7 +15,9 @@ navigator {
page {
--td-brand-color: rgba(0, 106, 106, 1);
--background: linear-gradient(135deg, rgba(0, 33, 33, 1), rgba(0, 106, 106, 1));
--area-bottom: calc(130rpx + env(safe-area-inset-bottom));
.t-navbar__capsule::before {
display: none;
}

View File

@@ -19,6 +19,11 @@ Component({
label: '首页',
icon: 'home'
},
{
path: "/pages/chat/index",
label: 'AI助手',
icon: 'chat-bubble'
},
{
path: "/pages/expert/index",
label: '专家服务',

292
pages/chat/index.js Normal file
View File

@@ -0,0 +1,292 @@
import request, { streamRequest } from "@/utils/request";
import { copyText } from "@/utils/common"
let outTimer; //输出定时器
let outStrList = [];//输出字符串数组
const app = getApp()
let chat_uuid
//工具
let toolText = ''
let toolInfo = {}
Page({
data: {
chatList: [], //会话记录
aiStatus: 3,//1表示接口响应中2表示接口响应完毕3表示完全输出完毕
keyboardBottom: 0, //底部键盘高度
scrollTop: 0, //滚动条位置
inputText: '',//输入框内容
},
onLoad() {
let str = `您好我是术极守护AI助手 🤖\n\n我可以为您解答术后康复相关的问题。请注意,我只能提供基于康复指南的一般性建议,如遇紧急情况请立即就医或联系您的主治医生。`
let mdResult = app.towxml(str, 'markdown', {
base: "www.xxx.com",
});
let list = [
{
loading: false,
end: true,
chat_type: 2,
md_content: mdResult,
chat_content: str,
quick_btn: [
{ text: '🩹伤口有轻微渗液怎么办?' },
{ text: '🌡️术后轻微发烧正常吗?' },
{ text: '🚿什么时候可以洗澡?' },
{ text: '🥗饮食需要注意什么?' }
]
}
]
this.setData({
chatList: list
})
},
onShow() {
this.getTabBar((tabBar) => {
tabBar.setData({
selected: 'AI助手',
})
})
},
//监听键盘弹出
bindkeyboardheightchange(event) {
let height = event.detail.height
let { keyboardBottom } = this.data
let scrollTop = 0
//获取当前滚动距离
const query = wx.createSelectorQuery().in(this)
query.select(".silder").scrollOffset()
query.exec(function (res) {
scrollTop = res[0].scrollTop
})
this.setData({
keyboardBottom: height > 0 ? height - 50 : keyboardBottom,
}, () => {
if (height > 0) {
setTimeout(() => {
this.setData({
scrollTop: scrollTop + height
})
}, 300)
}
})
},
//键盘失去焦点
bindkeyboardBlur() {
let { scrollTop, keyboardBottom } = this.data
let endScrollTop = scrollTop - keyboardBottom
this.setData({
keyboardBottom: 0
}, () => {
this.setData({
scrollTop: endScrollTop
})
})
},
//监听输入框内容
changeInput(e) {
let value = e.detail.value
this.setData({
inputText: value
})
},
async handQuick(e) {
let { data } = e.currentTarget.dataset
this.pushUserTemplate(data.text)
this.sendMessage(data.text)
},
// 文字提交发送
submitInput() {
let { inputText, aiStatus } = this.data
if (!inputText.trim() == '' && aiStatus) {
console.log('111--');
this.pushUserTemplate(inputText)
this.sendMessage(inputText)
}
},
//发送聊天
async sendMessage(text) {
let { chatList, aiStatus } = this.data
if (aiStatus == 3) {
let that = this
//初始化变量
toolText = ''
toolInfo = {}
chat_uuid = String(new Date().getTime())
this.setData({
aiStatus: 1
})
//输出
this.timerOutput()
//请求
streamRequest("/ai/chat", {
message: text,
chat_uuid: chat_uuid,
}, this.onChunkReceived).then(() => {
console.log('-------------成功-----------');
if (this.data.aiStatus != 3) {
that.setData({
aiStatus: 2
})
}
}).catch(() => {
console.log("------失败1---------");
that.data.chatList.at(-1).loading = false
that.setData({
aiStatus: 3,
chatList: chatList
})
})
}
},
//填充用户的聊天数据
pushUserTemplate(text) {
let { chatList } = this.data
let userItem = {
loading: false,
chat_type: 1,
con_type: 1,
chat_content: text,
md_content: '',
}
let aiItem = {
chat_type: 2,
chat_content: '',
md_content: '',
con_type: 1,
loading: true,
}
chatList.push(userItem)
chatList.push(aiItem)
//要修改的数据
let setData = {
chatList,
scrollTop: 80000,
inputText: ''
}
this.setData(setData, () => {
this.setData({
scrollTop: 80000
})
})
},
//只填充ai的默认回复
pushAiTemplate(text, options = {}) {
let { chatList } = this.data
let mdResult = app.towxml(text, 'markdown', {
base: "www.xxx.com",
});
let aiItem = {
chat_type: 2,
chat_content: text,
md_content: mdResult,
con_type: 1,
quick_btn: options.quick_btn || [],
...options
}
chatList.push(aiItem)
//要修改的数据
let setData = {
chatList,
scrollTop: 80000,
inputText: ''
}
this.setData(setData, () => {
this.setData({
scrollTop: 80000
})
})
},
//定时器输出
timerOutput() {
let { chatList } = this.data
let that = this
clearInterval(outTimer)
outStrList = []
let lastChat = this.data.chatList.at(-1)
//定时
outTimer = setInterval(async () => {
if ((outStrList.length == 0) && that.data.aiStatus == 2) {
clearInterval(outTimer)
lastChat.end = true
//如果有工具,设置工具对象
let text = lastChat.chat_content
// 上传AI响应结果
if (text) {
await request.post("/ai/history/upload", {
chat_content: text,
chat_uuid: chat_uuid,
})
}
//调用工具
if (toolText) { }
that.setData({
chatList,
aiStatus: 3
})
} else if (outStrList.length > 0) {
let firstValue = outStrList.shift();
lastChat.chat_content += firstValue
//转换md
let mdResult = app.towxml(lastChat.chat_content, 'markdown', {
base: "www.xxx.com",
});
lastChat.md_content = mdResult
lastChat.loading = false
that.setData({
chatList: chatList,
scrollTop: 80000
})
}
}, 50)
},
//流回调
onChunkReceived(data) {
data.forEach((item) => {
let value = item?.choices[0].delta.content ?? ''
let call = item?.choices[0].delta.tool_calls
//储存tool的id和名称
if (call) {
if (call[0].id) {
toolInfo.tool_call_id = call[0].id
toolInfo.name = call[0].function.name
}
let tool = call[0].function.arguments ?? ''
toolText += tool
}
if (value) {
outStrList.push(value)
}
})
},
//停止
async stopMessage() {
clearInterval(outTimer)
let lastChat = this.data.chatList.at(-1)
lastChat.chat_status = 2
this.setData({
aiStatus: 3,
chatList: this.data.chatList
})
await request.post("/ai/history/upload", {
chat_content: lastChat.chat_content || 'nocontent',
chat_status: 2,
chat_uuid: chat_uuid
})
},
//复制文字
copy(e) {
let { content, chat_content } = e.currentTarget.dataset.text
console.log(e);
if (!content) {
content = chat_content
}
copyText(content)
},
})

6
pages/chat/index.json Normal file
View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"towxml": "/towxml/towxml"
},
"navigationStyle": "custom"
}

179
pages/chat/index.scss Normal file
View File

@@ -0,0 +1,179 @@
@import "./makedown.scss";
.c-container {
display: flex;
flex-direction: column;
height: 100vh;
padding-bottom: var(--area-bottom);
.t-navbar__content {
backdrop-filter: blur(18px);
}
}
.silder {
overflow: auto;
flex: 1;
padding: 30rpx 0;
box-sizing: border-box;
.chat-item {
padding: 10rpx 30rpx;
&+.chat-item {
margin-top: 30rpx;
}
.lawyer-info {
margin-bottom: 20rpx;
font-size: 0.75rem;
image {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
margin-right: 10rpx;
display: block;
}
}
.chat-image {
width: 250rpx;
height: 250rpx;
border-radius: 20rpx;
}
.chat-message {
max-width: calc(100% - 100rpx);
min-height: 60rpx;
width: fit-content;
padding: 20rpx;
border-radius: 10rpx;
font-size: 0.9rem;
overflow: hidden;
.loading {
height: 40rpx;
width: 40rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 var(--td-brand-color);
animation: load 2s linear infinite alternate;
}
.stop-tip {
margin-top: 20rpx;
font-size: 0.64rem;
color: var(--text-3);
}
.m-footer {
margin-top: 10rpx;
.tip {
@extend .stop-tip;
margin-top: 0;
opacity: 0.1;
}
.sound-icon {
color: var(--text-2);
font-size: 1rem;
}
}
}
.quick-warpper {
font-size: 0.75rem;
gap: 30rpx;
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.quick-item {
background-color: white;
padding: 10rpx 20rpx;
border-radius: 50rpx;
border: 1px solid #dfdddd;
&.color {
color: white;
border-color: transparent;
;
}
}
}
}
.ai-msg {
.chat-message {
background-color: white;
border-radius: 20rpx 30rpx 30rpx 10rpx;
box-shadow: 0 0 15rpx #e2e2e2;
}
}
.user-msg {
display: flex;
flex-direction: column;
align-items: flex-end;
.chat-message {
background-color: var(--td-brand-color);
color: white;
border-radius: 30rpx 30rpx 0 30rpx;
}
}
}
.bottom-position {
margin: 0 30rpx;
position: relative;
//输入框
.input-warpper {
box-shadow: 0 0 15rpx #e2e2e2;
padding: 20rpx 30rpx;
background-color: white;
border-radius: 50rpx;
input {
flex: 1;
}
.push-btn {
border-radius: 50%;
width: 65rpx;
height: 65rpx;
color: var(--text-3);
transition: opacity 0.3s;
&.active-push {
background-color: var(--td-brand-color);
color: white;
}
}
.stop-btn {
font-size: 65rpx;
color: var(--td-brand-color);
}
}
}
//占位
.empty {
transition: height 0.3s;
}
//ai响应loading
@keyframes load {
0% {
box-shadow: inset -20rpx 40rpx 0 var(--td-brand-color);
}
100% {
box-shadow: inset 20rpx -40rpx 0 var(--td-brand-color);
}
}

103
pages/chat/index.wxml Normal file
View File

@@ -0,0 +1,103 @@
<page-meta root-font-size="system"></page-meta>
<view class="bg-gradient c-container">
<t-navbar class="custom-top-navbar"
fixed="{{false}}"
title="AI健康管家">
</t-navbar>
<scroll-view class="silder"
scroll-top="{{scrollTop}}"
scroll-y>
<view class="chat-item {{item.chat_type == 1 ? 'user-msg' : 'ai-msg'}}"
id="chat-${{index}}"
wx:for="{{chatList}}"
wx:key="index">
<view class="lawyer-info flex-align"
wx:if="{{item.chat_type !=1}}">
<text>AI助手</text>
</view>
<!-- 显示图片格式 -->
<view wx:if="{{item.con_type == 2}}">
<image src="{{item.chat_content}}"
class="chat-image"
data-url="{{item.chat_content}}"
bind:tap="viewImg"
mode="aspectFill" />
</view>
<view class="chat-message "
wx:else>
<view class="loading"
wx:if="{{item.loading}}"></view>
<view wx:elif="{{item.tool}}">
{{item.tool}}
</view>
<view wx:elif="{{item.chat_type == 1}}">
{{item.chat_content}}
</view>
<towxml wx:elif="{{item.chat_type == 2}}"
data-text="{{item}}"
bind:longpress="copy"
nodes="{{item.md_content}}" />
<!-- 底部功能 -->
<view wx:if="{{item.chat_status == 2}}"
class="stop-tip">
(已停止)
</view>
<view class="m-footer flex-between"
wx:elif="{{item.end}}">
<view class="tip">
AI助手仅提供基于康复指南的一般性建议不构成医疗诊断。如遇紧急情况请立即就医
</view>
<view class="flex-align">
<t-icon name="file-copy"
class="sound-icon"
data-text="{{item}}"
bind:tap="copy" />
</view>
</view>
</view>
<!-- 快捷按钮 -->
<view class="quick-warpper"
wx:if="{{item.quick_btn.length > 0 && item.end}}">
<view class="quick-item quick-{{quick.type}} {{quick.color ? 'color' : ''}}"
wx:for="{{item.quick_btn}}"
style="background-color: {{quick.color}};"
wx:for-item="quick"
wx:key="index"
bind:tap="handQuick"
data-parent="{{item}}"
data-data="{{quick}}">
{{quick.text}}
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="bottom-position">
<!-- 输入框 -->
<view class="input-warpper flex-align">
<input placeholder="有什么问题尽管问我"
bindblur="bindkeyboardBlur"
bindinput="changeInput"
bindconfirm="submitInput"
value="{{inputText}}"
hold-keyboard="{{false}}"
adjust-position="{{false}}"
bindkeyboardheightchange="bindkeyboardheightchange" />
<view class="push-btn flex-center {{inputText !='' ? 'active-push' : ''}} "
wx:if="{{aiStatus==3}}"
bind:tap="submitInput">
<t-icon name="send-filled" />
</view>
<t-icon name="stop-circle-filled"
class="stop-btn"
bind:tap="stopMessage"
wx:else />
</view>
</view>
<!-- 占位撑高 -->
<view style="height: {{keyboardBottom}}px;"
class="empty"></view>
</view>

17
pages/chat/makedown.scss Normal file
View File

@@ -0,0 +1,17 @@
.h2w__main,
.h2w__p {
padding: 0 !important;
margin: 0 !important;
font-size: 0.9rem;
}
.h2w__li {
margin-top: 20rpx;
}
.h2w__h3 {
font-size: 32rpx !important;
margin-top: 20rpx !important;
}

15
pages/chat/read.md Normal file
View File

@@ -0,0 +1,15 @@
聊天列表类型
``` typescript
interface ChatType{
loading: boolean, // 是否开始响应
end:boolean,//响应输出完成
chat_type: 1 | 2, // 1用户2ai普通响应
chat_content: string, //文本内容
md_content: string, //md格式
con_type:1, //消息类型1文本2图片
tool:string, //用工具时的默认内容,为空代表不是工具
chat_status?:number, //2停止
quick_btn:[], //快捷菜单按钮,为空不显示
drugList:[],//药品信息,只用来设置提醒
}
```

View File

@@ -1,9 +1,77 @@
import request from "@/utils/request"
const app = getApp()
Page({
data: {
list: [
{
s: "李",
name: "李医生",
title: "主任医师 · 骨科专家",
desc: "20年临床经验"
},
{
s: "王",
name: "王医生",
title: "副主任医师 · 康复医学",
desc: "15年临床经验"
},
{
s: "张",
name: "张护士长",
title: "主管护师 · 伤口护理",
desc: "18年护理经验"
}
],
count: 0
},
onShow() {
let userInfo = app.globalData.userInfo
this.setData({
count: userInfo.consult_remains
})
this.getTabBar((tabBar) => {
tabBar.setData({
selected: '专家服务',
})
})
},
async onPlay() {
if (this.data.count <= 0) {
wx.showToast({
title: '免费次数已用完',
icon: 'none'
});
return;
}
// 2. 弹窗确认
wx.showModal({
title: '拨打专家热线',
content: '确认拨打专家咨询电话吗本次咨询将使用1次免费机会',
success: async (res) => {
if (res.confirm) {
try {
wx.showLoading({ title: '正在呼叫...', mask: true });
const info = await request.get("/expert/phone");
this.setData({
count: this.data.count - 1
});
wx.hideLoading();
// 5. 吊起拨号盘
if (info && info.expert_phone) {
wx.makePhoneCall({
phoneNumber: info.expert_phone,
});
} else {
wx.showToast({ title: '暂无专家电话', icon: 'none' });
}
} catch (error) {
wx.hideLoading();
}
}
}
});
}
})

View File

@@ -1,5 +1,6 @@
page {
background-color: rgba(247, 249, 252, 1);
padding-bottom: 200rpx;
}
.head {
@@ -47,11 +48,175 @@ page {
color: rgba(255, 255, 255, 0.8);
}
}
.btn{
.btn {
padding: 50rpx 0;
background-color: rgba(29, 120, 116, 1);
}
.c-2{
.service-card {
width: 100%;
background-color: #fff;
border-radius: 12px;
padding: 16px;
box-sizing: border-box;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05);
margin-top: 30rpx;
.flex-align {
display: flex;
align-items: center;
}
.header {
.icon {
width: 32px;
height: 32px;
background-color: #f0f5f0; // 图标背景色
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
margin-right: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
}
.content {
margin-top: 12px;
.row {
display: flex;
justify-content: space-between;
margin-top: 6px;
.label {
font-size: 14px;
color: #666;
}
.time {
font-size: 14px;
font-weight: 600;
color: #18a74f; // 绿色
}
}
}
.status {
display: flex;
align-items: center;
margin-top: 12px;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #18a74f;
margin-right: 6px;
}
.text {
font-size: 12px;
color: #18a74f;
}
}
}
.emergency-card {
background: rgba(255, 235, 238, 1);
border-radius: 16rpx;
padding: 32rpx;
margin-top: 30rpx;
gap: 30rpx;
display: flex;
align-items: flex-start;
--color: rgba(198, 40, 40, 1);
color: var(--color);
.icon {
width: 90rpx;
aspect-ratio: 1;
background-color: rgba(186, 26, 26, 0.15);
border-radius: 20rpx;
}
.t-1 {
font-weight: 700;
font-size: 34rpx;
margin-bottom: 20rpx;
}
.num {
font-weight: 700;
font-size: 40rpx;
}
.item {
font-size: 28rpx;
line-height: 60rpx;
display: flex;
align-items: center;
gap: 20rpx;
&::before {
content: "";
display: block;
width: 8rpx;
height: 8rpx;
background-color: var(--color);
border-radius: 50%;
}
}
}
.service {
background-color: white;
--color: black;
.icon {
background-color: rgba(0, 106, 106, 0.1);
}
}
.express {
background-color: white;
padding: 30rpx;
border-radius: 16rpx;
margin-top: 30rpx;
.item {
gap: 30rpx;
margin-top: 40rpx;
.icon {
width: 100rpx;
aspect-ratio: 1;
border-radius: 50%;
color: white;
font-weight: 700;
background: linear-gradient(rgba(29, 120, 116, 1), rgba(46, 139, 139, 1));
}
.t-1 {
font-weight: 700;
}
.t-2 {
font-size: 28rpx;
color: rgba(69, 70, 78, 1);
}
.t-3 {
font-size: 24rpx;
color: rgba(69, 70, 78, 1);
}
}
}
}

View File

@@ -14,21 +14,91 @@
<t-icon name="check-circle" />
可咨询次数
</view>
<view class="num">3</view>
<view class="num">{{count}}</view>
</view>
<view class="tip">本月剩余 3 次免费咨询机会</view>
<view class="tip">本月剩余 {{count}} 次免费咨询机会</view>
</view>
<t-button block
theme="primary"
class="btn"
icon="call-1">
icon="call-1"
bind:tap="onPlay">
一键拨打专家电话
</t-button>
<view class="c-2 flex-align">
<view class="service-card">
<view class="header flex-align">
<view class="icon">
<t-icon name="time" />
</view>
<view class="title">服务时间</view>
</view>
<view class="content">
<view class="row">
<view class="label">工作日</view>
<view class="time">09:00 - 21:00</view>
</view>
<view class="row">
<view class="label">周末及节假日</view>
<view class="time">10:00 - 18:00</view>
</view>
</view>
<view class="status">
<view class="dot"></view>
<view class="text">当前服务中</view>
</view>
</view>
<view class="emergency-card">
<view class="icon flex-center">
<t-icon name="error-triangle" />
</view>
<view style="flex: 1;">
<view class="t-1">紧急情况处理</view>
<view>如遇以下紧急情况,请立即拨打</view>
<view style="margin-bottom: 20rpx;"><text class="num">120</text> 或前往急诊</view>
<view class="list">
<view class="item">大量出血或伤口裂开</view>
<view class="item">高热不退体温≥39°C</view>
<view class="item">剧烈疼痛无法缓解</view>
<view class="item">呼吸困难或胸痛</view>
<view class="item">意识模糊或昏迷</view>
</view>
</view>
</view>
<view class="emergency-card service">
<view class="icon flex-center">
<t-icon name="info-circle" />
</view>
<view style="flex: 1;">
<view class="t-1">服务说明</view>
<view class="list">
<view class="item">每次通话时长不超过15分钟</view>
<view class="item">专家会根据您的情况提供专业建议</view>
<view class="item">复杂问题可能需要您到院面诊</view>
<view class="item">建议提前准备好相关检查报告</view>
<view class="item">咨询记录会同步到您的康复档案</view>
</view>
</view>
</view>
<view class="express">
<view style="font-weight: 700;">专家团队</view>
<view class="item flex-align"
wx:for="{{list}}"
wx:key="index">
<view class="icon flex-center">{{item.s}}</view>
<view>
<view class="t-1">{{item.name}}</view>
<view class="t-2">{{item.title}}</view>
<view class="t-3">{{item.desc}}</view>
</view>
</view>
</view>
</view>

View File

@@ -1,7 +1,15 @@
import request from "@/utils/request"
Page({
data: {
detail: {
overview: {},
tasks: [{}, {}, {}],
},
loading: true,
},
onLoad() {
this.init()
},
onShow() {
@@ -10,5 +18,31 @@ Page({
selected: '首页',
})
})
},
async init() {
let res = await request.get("/home")
this.setData({
detail: res,
loading: false,
})
},
//手动标记是否完成
handDone(e) {
const { data } = e.currentTarget.dataset;
const newTasks = this.data.detail.tasks.map(v => {
if (v.task_id == data.task_id) {
// 如果当前是 1 则变为 0如果是 0 则变为 1
return { ...v, is_completed: v.is_completed == 1 ? 0 : 1 };
}
return v;
});
request.post("/home/task-record", {
...data,
is_completed: data.is_completed == 1 ? 0 : 1
})
this.setData({
'detail.tasks': newTasks
});
}
})

View File

@@ -1,5 +1,6 @@
page {
background-color: rgba(247, 249, 252, 1);
padding-bottom: 160rpx;
}
.header {
@@ -78,11 +79,15 @@ page {
width: 90rpx;
aspect-ratio: 1;
border-radius: 20rpx;
background-color: var(--color);
background-color: var(--back);
color: rgba(99, 63, 0, 1);
}
.content {
flex: 1;
--td-tag-default-light-color: var(--back);
--td-tag-default-font-color: var(--color);
.time {
margin-left: 20rpx;
color: rgba(69, 70, 78, 1);

View File

@@ -1,22 +1,25 @@
<wxs src="/utils/wxs/comment.wxs"
module="utils"></wxs>
<view class="header">
<t-navbar class="fixed-nav"
fixed="{{false}}" />
<view class="h-1">
<view class="left">
<view class="t-1">术后第 2 天</view>
<view class="t-2">康复进行中</view>
<view class="t-1">术后第 {{detail.overview.postoperative_day || 0}} 天</view>
<view class="t-2">{{detail.overview.status_text}}</view>
</view>
<view class="right flex-column-center ">
<view class="d">2</view>
<view class="d">{{detail.overview.postoperative_day || 0}} </view>
<view class="c">DAYS</view>
</view>
</view>
<view class="h-2">
<view class="flex-between title">
<view>今日任务完成度</view>
<view class="num">0/5</view>
<view class="num">{{taskUtils.getDoneCount(detail.tasks)}}/{{detail.overview.total_task_count}}</view>
</view>
<t-progress percentage="80"
<t-progress percentage="{{taskUtils.getDoneCount(detail.tasks) / detail.overview.total_task_count * 100}}"
color="{{ ['white', 'white'] }}"
track-color="rgba(255, 255, 255, 0.2)"
label="" />
@@ -25,20 +28,31 @@
<view class="list">
<view class="item flex-align"
wx:for="{{5}}"
style="--color:{{item.label_color}};--back:{{utils.getLightColor(item.label_color)}}"
wx:for="{{detail.tasks}}"
wx:key="index">
<t-skeleton loading="{{loading}}"
rowCol="{{[{ size: '90rpx' }]}}">
<view class="icon flex-center">
<t-icon name="apple" />
<t-icon name="{{item.pill}}" />
</view>
</t-skeleton>
<view class="content">
<t-skeleton loading="{{loading}}"
theme="paragraph">
<view class="flex-align">
<t-tag variant="light"
theme="success">用药</t-tag>
<view class="time">08:00</view>
<t-tag variant="light">{{item.category_label}}</t-tag>
<view class="time">{{item.time_label}}</view>
</view>
<view class="title">服用抗生素</view>
<view class="desc">头孢类抗生素,饭后服用</view>
<button class="btn">标记完成</button>
<view class="title">{{item.title}}</view>
<view class="desc">{{item.content}}</view>
<button class="btn {{item.is_completed == 1 ? 'active' : ''}}"
bind:tap="handDone"
data-data="{{item}}">
{{item.is_completed == 0 ? '标记完成' : '已完成' }}
</button>
</t-skeleton>
</view>
</view>
</view>
@@ -48,3 +62,21 @@
<view class="text"> 请按时完成康复任务,如有任何不适请及时联系医护人员</view>
</view>
<view style="height: 30rpx;"></view>
<wxs module="taskUtils">
var getDoneCount = function (tasks) {
if (!tasks || !tasks.length) return 0;
var count = 0;
for (var i = 0; i < tasks.length; i++) {
if (tasks[i].is_completed == 1 || tasks[i].is_completed === true) {
count++;
}
}
return count;
};
module.exports = {
getDoneCount: getDoneCount
};
</wxs>

View File

@@ -1,31 +0,0 @@
// pages/auth/access/index.js
Page({
data: {
},
handClick(e) {
let { type } = e.currentTarget.dataset
//拍照
if (type == 1) {
wx.scanCode({
onlyFromCamera: true,
scanType: ['qrCode'],
success(res) {
console.log(res);
},
fail(err) {
wx.showToast({
title: '扫码失败',
icon: 'none'
})
}
})
} else {
wx.navigateTo({
url: "/pages/joinFlow/manual/index",
})
}
}
})

View File

@@ -1,4 +0,0 @@
{
"usingComponents": {},
"navigationStyle": "custom"
}

View File

@@ -1,80 +0,0 @@
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.banner {
height: 400rpx;
background-image: var(--background);
padding: 30rpx;
.text-1 {
color: white;
font-size: 60rpx;
font-weight: bold;
}
.text-2 {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
}
.content {
flex: 1;
background-color: white;
transform: translateY(-20rpx);
border-radius: 30rpx 30rpx 0 0;
padding: 80rpx 30rpx 30rpx;
.text-1 {
text-align: center;
font-weight: 700;
font-size: 45rpx;
}
.text-2 {
font-size: 28rpx;
text-align: center;
color: rgba(69, 70, 78, 1);
margin-top: 20rpx;
margin-bottom: 60rpx;
}
.item {
box-shadow: 0 4rpx 12rpx rgba(11, 27, 61, 0.14);
padding: 30rpx;
border-radius: 30rpx;
gap: 30rpx;
margin-bottom: 60rpx;
.icon {
width: 90rpx;
aspect-ratio: 1;
background-color: rgba(0, 106, 106, 0.1);
font-size: 50rpx;
border-radius: 20rpx;
color: rgba(46, 139, 87, 1);
}
.t-1 {
font-weight: bold;
}
.t-2 {
margin-top: 10rpx;
font-size: 24rpx;
color: rgba(69, 70, 78, 1);
}
}
.tip {
background-color: rgba(255, 221, 180, 1);
padding: 30rpx;
border-radius: 20rpx;
color: rgba(99, 63, 0, 1);
font-size: 28rpx;
line-height: 44rpx;
}
}

View File

@@ -1,40 +0,0 @@
<view class="banner">
<t-navbar t-class="fixed-nav"
fixed="{{false}}" />
<view class="info">
<view class="text-1">术极守护</view>
<view class="text-2">您的贴身康复管家</view>
</view>
</view>
<view class="content">
<view class="text-1">开始使用</view>
<view class="text-2">请选择康复计划接入方式</view>
<view class="item flex-align"
bind:tap="handClick"
data-type="1">
<view class="icon flex-center">
<t-icon name="qrcode" />
</view>
<view>
<view class="t-1">扫码接入</view>
<view class="t-2">扫描医护人员提供的二维码</view>
</view>
</view>
<view class="item flex-align"
bind:tap="handClick"
data-type="2">
<view class="icon flex-center">
<t-icon name="search" />
</view>
<view>
<view class="t-1">手动选择</view>
<view class="t-2">搜索医院、科室、手术名称</view>
</view>
</view>
<view class="tip">
<view>💡 温馨提示:</view>
<view>首次使用需要录入基本信息,请准备好您的手术相关资料</view>
</view>
</view>

View File

@@ -1,36 +0,0 @@
Page({
data: {
formData: {
hospital: "",
department: ""
}
},
onInputChange(e) {
console.log("dsds");
const {field} = e.currentTarget.dataset;
this.setData({
[`formData.${field}`]: e.detail.value,
});
},
onSubmit() {
let {formData} = this.data
let errorText = ""
if (!formData.hospital.trim()) {
errorText = "请填写医院名称"
}
if (!formData.department.trim()) {
errorText = "请填写科室名称"
}
if (errorText) {
wx.showToast({
title: errorText,
icon: "none"
})
return;
}
wx.navigateTo({
url: "/pages/joinFlow/person/index"
})
}
})

View File

@@ -1,4 +0,0 @@
{
"usingComponents": {},
"navigationStyle": "custom"
}

View File

@@ -1,8 +0,0 @@
.content {
margin-top: 60rpx;
.btn {
margin-top: 60rpx;
padding: 40rpx 30rpx;
}
}

View File

@@ -1,26 +0,0 @@
<t-navbar title="选择康复计划"
left-arrow
fixed="{{false}}" />
<view class="content">
<t-input value="{{formData.hospital}}"
borderless
label="医院"
placeholder="请输入医院名称"
bind:change="onInputChange"
data-field="hospital" />
<t-input value="{{formData.department}}"
borderless
label="科室"
clearable="{{false}}"
placeholder="请输入科室名称"
bind:change="onInputChange"
data-field="department" />
<view class="btn">
<t-button block
theme="primary" bind:tap="onSubmit">
确认
</t-button>
</view>
</view>

View File

@@ -1,21 +1,100 @@
import request from "@/utils/request"
import { getToken, setToken } from "@/utils/auth/manageToken"
import { formatDate } from "@/utils/common"
const app = getApp()
Page({
data: {
loading: true,
parmas: {},
mood: "manual",
showPicker: false,
date: new Date().getTime(),
//手术
showSurgery: false,
surgeryList: [],
selectSurgery: [],
//医院信息
hospital: "",
department: "",
//其他信息
age: "",
allergy: "",
comorbidity: "",
},
onLoad(e) {
if (Object.keys(e).length > 0) {
this.setData({
parmas: e,
mood: "scan"
})
}
this.init()
},
//初始化
async init() {
let token = getToken()
//如果存在
if (token) {
let info = await request.get("/my-info")
this.handUser(info)
} else {
wx.login({
success: async (res) => {
let response = await request.post("/login", {
"wx_code": res.code
})
setToken(response.accessToken)
this.handUser(response)
},
})
}
},
//处理信息
async handUser(data) {
app.globalData.userInfo = data.userInfo
if (data.needProfile == 0) {
wx.switchTab({
url: "/pages/home/index",
})
} else {
let res = await request.get("/profile/surgicals")
this.setData({
surgeryList: res.list,
})
}
this.setData({
loading: false,
})
},
onInputChange(e) {
const { field } = e.currentTarget.dataset;
this.setData({
[`${field}`]: e.detail.value,
});
},
//选择手术
changeSurgeryShow() {
this.setData({
showSurgery: !this.data.showSurgery
})
},
handSelectSurgery(e) {
let { data } = e.currentTarget.dataset
let { selectSurgery } = this.data
let isHave = selectSurgery.find(item => item.id == data.id)
if (isHave) {
this.setData({
selectSurgery: selectSurgery.filter((item) => item.id != data.id)
})
} else {
this.setData({
selectSurgery: [...selectSurgery, data]
})
}
},
//选择时间
chaneTimeShow() {
this.setData({
@@ -29,22 +108,26 @@ Page({
})
},
//选择手术
changeSurgeryShow() {
this.setData({
showSurgery: !this.data.showSurgery
})
},
//提交
onSubmit() {
let { date, age, allergy, comorbidity } = this.data
async onSubmit() {
let { mood, hospital, department, date, age, allergy, comorbidity, parmas, selectSurgery } = this.data
let errorText = ""
if (!date) {
if (mood == 'manual' && !hospital.trim()) {
errorText = "请输入医院"
}
else if (mood == 'manual' && !department.trim()) {
errorText = "请输入科室"
}
else if (selectSurgery.length == 0) {
errorText = "请选择手术"
}
else if (!date) {
errorText = "请选择时间"
}
if (!age.trim()) {
else if (!age.trim()) {
errorText = "请填写年龄"
}
if (errorText) {
@@ -54,8 +137,29 @@ Page({
})
return
}
try {
wx.showLoading({
title: '提交中',
mask: true
})
await request.post("/profile", {
entry_mode: mood,
...parmas,
hospital_name: hospital,
department_name: department,
surgical_ids: selectSurgery.map(item => item.id),
surgical_date: formatDate(date, 'YYYY-MM-DD'),
age: age,
allergy_history: allergy,
complication: comorbidity
})
wx.hideLoading()
wx.switchTab({
url: '/pages/home/index',
})
} catch (e) {
console.log(e);
wx.hideLoading()
}
}
})

View File

@@ -1,4 +1,4 @@
page {
.page {
height: 100vh;
display: flex;
flex-direction: column;
@@ -21,6 +21,7 @@ page {
}
}
.content {
flex: 1;
height: 0;
@@ -29,6 +30,10 @@ page {
border-radius: 30rpx 30rpx 0 0;
padding: 50rpx 0 0;
.ce {
white-space: nowrap;
}
.text-1 {
font-weight: 700;
font-size: 45rpx;

View File

@@ -1,21 +1,41 @@
<wxs src="/utils/wxs/comment.wxs"
module="utils"></wxs>
<view class="banner">
<view class="page"
wx:if="{{!loading}}">
<view class="banner">
<t-navbar t-class="fixed-nav"
fixed="{{false}}" />
<view class="info">
<view class="text-1">术极守护</view>
<view class="text-2">您的贴身康复管家</view>
</view>
</view>
</view>
<scroll-view class="content"
<scroll-view class="content"
scroll-y>
<view class="text-1">建立康复档案</view>
<t-cell title="医院"
wx:if="{{mood == 'manual'}}">
<input slot="note"
placeholder="请输入医院"
data-field="hospital"
bind:change="onInputChange" />
</t-cell>
<t-cell title="科室"
wx:if="{{mood == 'manual'}}">
<input slot="note"
placeholder="请输入科室"
data-field="department"
bind:change="onInputChange" />
</t-cell>
<t-cell title="选择手术"
arrow
note="请选择"
t-class-title="ce"
bind:tap="changeSurgeryShow">
<view slot="note">
{{select.getNames(selectSurgery)}}
</view>
</t-cell>
<t-cell title="手术日期"
arrow
@@ -42,14 +62,23 @@
data-field="comorbidity"
bind:change="onInputChange" />
</t-cell>
</scroll-view>
<view class="btn">
</scroll-view>
<view class="btn">
<t-button block
theme="primary"
bind:tap="onSubmit">下一步
</t-button>
</view>
</view>
<view class="flex-center"
style="height: 100vh;"
wx:else>
<t-loading theme="circular"
size="40rpx" />
</view>
<t-popup visible="{{showSurgery}}"
placement="bottom"
bind:visible-change="changeSurgeryShow">
@@ -57,9 +86,14 @@
<view class="title">手术列表</view>
<scroll-view class="surgery-list"
scroll-y>
<view class="flex-between surgery-item " wx:for="{{6}}" wx:key="index">
<view class="name">测试手术名字</view>
<t-icon name="check" />
<view class="flex-between surgery-item "
wx:for="{{surgeryList}}"
wx:key="index"
data-data="{{item}}"
bind:tap="handSelectSurgery">
<view class="name">{{item.name}}</view>
<t-icon name="check"
wx:if="{{select.isSelected(item.id, selectSurgery)}}" />
</view>
</scroll-view>
</view>
@@ -73,3 +107,27 @@
format="YYYY-MM-DD"
bindconfirm="onTimeConfirm"
bindclose="chaneTimeShow" />
<wxs module="select">
module.exports = {
isSelected: function (id, list) {
if (!list || !list.length) return false;
for (var i = 0; i < list.length; i++) {
if (list[i].id === id) return true;
}
return false;
},
getNames: function (list) {
if (!list || !list.length) return "请选择";
// 拼接名字,逗号分隔
var names = "";
for (var i = 0; i < list.length; i++) {
names += list[i].name;
if (i < list.length - 1) names += ", ";
}
return names;
}
};
</wxs>

View File

@@ -12,20 +12,19 @@ Page({
let state = wx.getStorageSync(this.data.key);
if (state) {
wx.redirectTo({
url: "/pages/joinFlow/access/index",
url: "/pages/joinFlow/person/index",
})
} else {
this.setData({
loading: false
})
}
},
handNext() {
wx.setStorageSync(this.data.key, true)
wx.redirectTo({
url: "/pages/home/index",
url: "/pages/joinFlow/person/index"
})
}
})

View File

@@ -1,6 +1,6 @@
{
"compileType": "miniprogram",
"libVersion": "trial",
"libVersion": "3.15.1",
"setting": {
"coverView": true,
"es6": true,

View File

@@ -3,7 +3,7 @@
"projectname": "wx_app_template",
"setting": {
"compileHotReLoad": true,
"urlCheck": true,
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
@@ -20,17 +20,5 @@
"bigPackageSizeSupport": false
},
"libVersion": "3.15.1",
"condition": {
"miniprogram": {
"list": [
{
"name": "pages/home/index",
"pathName": "pages/home/index",
"query": "",
"scene": null,
"launchMode": "default"
}
]
}
}
"condition": {}
}

View File

@@ -73,3 +73,19 @@ export function uploadQiuFile(path, file, is_public = 1) {
})
})
}
/**
* 复制文字内容
* @param {文本} text
*/
export function copyText(text) {
wx.setClipboardData({
data: text,
success() {
wx.showToast({
title: '已复制到剪切板',
icon: 'none'
})
},
})
}

View File

@@ -2,11 +2,11 @@ import httpEnv from "./auth/env"
import { getToken, removeToken } from "./auth/manageToken"
const baseApi = {
//开发版
develop: "https://curainwxapp.test.tuzuu.com/api",
develop: "https://kairos-wx.test.tuzuu.com/api",
// 体验版
trial: "https://curainwxapp.test.tuzuu.com/api",
trial: "https://kairos-wx.test.tuzuu.com/api",
// 正式版
release: "https://yidaojia.cells.org.cn/api"
release: "https://kairos-wx.test.tuzuu.com/api",
}
//获取当前环境的接口前缀
export const baseUrl = baseApi[httpEnv]
@@ -82,4 +82,65 @@ request.post = function (url, options) {
})
}
//流式
let lastChunk = '' //流可能会被截断,需要补充
export function streamRequest(url, data, onChunkReceived) {
return new Promise((resolve, reject) => {
const token = getToken()
let header = {}
if (token) {
header.Authorization = 'Bearer ' + token;
}
let requestTask = wx.request({
url: baseUrl + url,
data: data || {},
method: 'POST',
header,
timeout: 100000,
responseType: "arraybuffer",
enableChunked: true, //关键!开启流式传输模式
success: () => {
resolve()
},
fail: (e) => {
console.log(e);
reject()
}
})
//处理流
if (onChunkReceived) {
lastChunk = ''
requestTask.onChunkReceived((response) => {
let data = response.data;
// console.log(decodeStream(data));
onChunkReceived(decodeStream(data))
})
}
})
}
//流素具解码
function decodeStream(data) {
let uint8Array = new Uint8Array(data); // 将 ArrayBuffer 转换为 Uint8Array
let responseText = decodeURIComponent(escape(String.fromCharCode.apply(null, uint8Array))); // 使用 apply 扩展字节
// 处理数据,移除前缀等操作
if (lastChunk) {
responseText = "data: " + lastChunk + responseText
lastChunk = ""
}
// 处理数据,移除前缀等操作
responseText = responseText.replaceAll("data: ", '');
let jsonStrings = responseText.trim().split(/\n+/);
// 过滤掉解析失败的部分
let jsonArray = jsonStrings.map(jsonStr => {
try {
return JSON.parse(jsonStr);
} catch (e) {
lastChunk = jsonStr
console.error('JSON格式不正确(已做兼容处理):');
return null; // 如果解析失败返回null
}
}).filter(item => item !== null);
return jsonArray
}
export default request

View File

@@ -14,7 +14,7 @@ module.exports = {
date = getDate(date)
var YYYY = date.getFullYear();
var MM = ('0' + (date.getMonth() + 1)).slice(-2)
var DD =('0' + date.getDate()).slice(-2);
var DD = ('0' + date.getDate()).slice(-2);
var hh = ("0" + date.getHours()).slice(-2);
var mm = ("0" + date.getMinutes()).slice(-2);
var ss = ("0" + date.getSeconds()).slice(-2);
@@ -26,4 +26,38 @@ module.exports = {
.replace(getRegExp('ss'), ss)
return result;
},
/**
* 获取浅色标签颜色
* @param {string} hexColor 如 "#e3f9e9"
*/
getLightColor: function (color, level) {
if (!color || color.indexOf('#') === -1) return color;
// 默认变浅程度
var weight = level !== undefined ? level : 0.9;
// 处理 #fff 这种简写
var hex = color.slice(1);
if (hex.length === 3) {
hex = hex.charAt(0) + hex.charAt(0) + hex.charAt(1) + hex.charAt(1) + hex.charAt(2) + hex.charAt(2);
}
// 提取 RGB
var r = parseInt(hex.substring(0, 2), 16);
var g = parseInt(hex.substring(2, 4), 16);
var b = parseInt(hex.substring(4, 6), 16);
// 与白色(255)混合逻辑:新颜色 = 原颜色 * (1 - weight) + 255 * weight
r = Math.floor(r * (1 - weight) + 255 * weight);
g = Math.floor(g * (1 - weight) + 255 * weight);
b = Math.floor(b * (1 - weight) + 255 * weight);
// 转回 16 进制
var toString16 = function (n) {
var s = n.toString(16);
return s.length === 1 ? '0' + s : s;
};
return '#' + toString16(r) + toString16(g) + toString16(b);
}
};