From 2d2a8abfd4e87d74dc231d6abbeaa0f2fe2e9fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Sun, 19 Jul 2026 16:08:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(home/chat):=20=E5=88=97=E8=A1=A8=E9=AA=A8?= =?UTF-8?q?=E6=9E=B6=E5=B1=8F=E3=80=81=E8=81=8A=E5=A4=A9=E5=AE=A4=E5=B7=B1?= =?UTF-8?q?=E6=96=B9=E5=A4=B4=E5=83=8F=E4=B8=8E=E5=8D=8F=E8=AE=AE/?= =?UTF-8?q?=E5=8C=BB=E7=94=9F=E8=AF=A6=E6=83=85=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增分包组件 ListSkeleton(doctor/message/product/article/category/zone) - 首页/消息/资讯首次加载改用骨架屏,刷新不再清空旧数据避免闪空 · 去掉 home getList、消息 onShow 中的数组清空 · 三页 pages.json 增加 easycom 与 componentPlaceholder 主包异步引用 - 聊天室己方头像改为读取本地 userinfo.avatarurl(登录后微信头像), mycollection 上传头像成功后同步写回 storage - 修复协议抽屉点不开:page-container 改为 v-if + :show; 协议名单独可点节点,匹配放宽 name/desc 双向 includes - 医生气泡头像可点击进入 doctor-detail?readonly=1 只读医生详情, 隐藏预约挂号区 --- .cursor/rules/Code-Standards.md | 2 + .cursor/rules/Code-Standards.mdc | 2 + pages.json | 22 +- pages/cate/index.vue | 57 +- pages/home/home.vue | 151 +++-- pages/index/index.vue | 220 ++++--- request/api/api.js | 6 +- request/api/request.js | 20 +- subPackages/chat/chat.vue | 83 ++- subPackages/chat/components/MessageBubble.vue | 103 +++- .../common/components/ListSkeleton.vue | 275 +++++++++ .../doctor/components/FollowUpDrugDrawer.vue | 508 ++++++++++++++- subPackages/doctor/doctor-detail.vue | 25 +- subPackages/my/my-drug/drug-list.vue | 8 +- subPackages/my/mycollection.vue | 23 + subPackages/my/myrecord-detail.vue | 10 +- subPackages/shop/logistics.vue | 583 +++++++++++------- utils/content-security.js | 6 +- utils/formatStoreNameWithHu.js | 12 + utils/utils.js | 4 +- utils/ws/websocket.js | 3 +- 21 files changed, 1657 insertions(+), 466 deletions(-) create mode 100644 subPackages/common/components/ListSkeleton.vue create mode 100644 utils/formatStoreNameWithHu.js diff --git a/.cursor/rules/Code-Standards.md b/.cursor/rules/Code-Standards.md index fa8db67..4e74dd2 100644 --- a/.cursor/rules/Code-Standards.md +++ b/.cursor/rules/Code-Standards.md @@ -8,3 +8,5 @@ alwaysApply: true 3. 有封装好的方法、组件需要复用,不要重复造轮子 4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show) 5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化 +6. 微信小程序 `.vue` 模板(会编译成 WXML)禁止使用 `??`、`?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.` +7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定 diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index fa8db67..4e74dd2 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -8,3 +8,5 @@ alwaysApply: true 3. 有封装好的方法、组件需要复用,不要重复造轮子 4. 小程序端的抽屉全部需要用page-container来防止用户意外退出页面(记得使用v-if而不是v-show) 5. 数据库的(created_at、updated_at、deleted_at)统一使用时间戳,不要使用字符串,并且我在查询器中一级格式化成字符串了,无需再次格式化 +6. 微信小程序 `.vue` 模板(会编译成 WXML)禁止使用 `??`、`?.` 等现代运算符,否则会报 `unexpected token '?'`;模板里用 `||` / 显式判断,脚本里可用 `??`/`?.` +7. 微信小程序模板里 `:class` / `:style` 禁止写 `fn(arg)` 方法调用(如 `:class="sexClass(p)"` 会编译失败);用对象/数组字面量(如 `:class="{ male: p.sex == 1 }"`),或把结果先算进 data/computed 再绑定 diff --git a/pages.json b/pages.json index fa4ef8c..6f767fb 100644 --- a/pages.json +++ b/pages.json @@ -7,17 +7,23 @@ "^u-(.*)": "uview-ui/components/u-$1/u-$1.vue", // 消息通知组件 "^MessageNotification": "@/components/MessageNotification/MessageNotification.vue", - "^FollowUpDrugDrawer$": "@/subPackages/doctor/components/FollowUpDrugDrawer.vue" + "^FollowUpDrugDrawer$": "@/subPackages/doctor/components/FollowUpDrugDrawer.vue", + // 列表骨架屏(分包组件,主包页需配合 componentPlaceholder) + "^ListSkeleton$": "@/subPackages/common/components/ListSkeleton.vue" }, "pages": [ { "path": "pages/home/home", "style": { // "navigationBarTitleText": "custom", - "navigationStyle": "custom" + "navigationStyle": "custom", // "navigationStyle": "default", // "navigationBarBackgroundColor": "#298DFF", // "navigationBarTextStyle": "white" + // 主包异步引用分包 ListSkeleton + "componentPlaceholder": { + "list-skeleton": "view" + } } }, { @@ -53,7 +59,11 @@ { "path": "pages/cate/index", "style": { - "navigationBarTitleText": "健康资讯" + "navigationBarTitleText": "健康资讯", + // 主包异步引用分包 ListSkeleton + "componentPlaceholder": { + "list-skeleton": "view" + } } // "needLogin": true }, @@ -67,7 +77,11 @@ { "path": "pages/index/index", "style": { - "navigationBarTitleText": "消息" + "navigationBarTitleText": "消息", + // 主包异步引用分包 ListSkeleton + "componentPlaceholder": { + "list-skeleton": "view" + } } // "needLogin": true }, diff --git a/pages/cate/index.vue b/pages/cate/index.vue index 05fef2c..d8db7c3 100644 --- a/pages/cate/index.vue +++ b/pages/cate/index.vue @@ -13,7 +13,9 @@ - + + - - - - - - {{item.title}} + + + @@ -65,7 +70,18 @@ selectIndex: 0, arr: [], // 储存距离顶部高度的数组 scrollRightTop: 0, // 右边栏目scroll-view的滚动条高度 - show: false + show: false, + // 首次加载骨架 + categoryInitialLoading: true, + articleInitialLoading: true, + // 切分类且当前列表为空时拉文章期间展示骨架 + articleFetchingEmpty: false + } + }, + computed: { + // 是否展示右侧文章骨架:首次加载,或请求中且当前无文章可保留 + showArticleSkeleton() { + return this.articleInitialLoading || this.articleFetchingEmpty } }, onLoad(op) { @@ -86,14 +102,14 @@ url: '/pages/cate/info-detail?id=' + e }) }, - //点击药品一级分类e + //点击药品一级分类e:不清空 infoList,有旧数据则保留到新结果返回 handleCategory1(e, index) { this.num = e this.cid = e this.getArticleList() }, - //获取药品分类 + //获取药品分类:成功后再覆盖,失败保留旧分类 getList() { categoriesList({ method: "post", @@ -101,12 +117,13 @@ store_id: uni.getStorageSync('store_id') || '11001', } }).then((res) => { - // console.log(res, 'list'); if (res.data.code == 0) { const list = (res.data.result && res.data.result.list) || [] this.categoryLists = list if (!list.length) { this.infoList = [] + this.articleInitialLoading = false + this.articleFetchingEmpty = false return } const firstId = list[0].id @@ -114,10 +131,15 @@ this.cid = firstId this.getArticleList() } + }).finally(() => { + this.categoryInitialLoading = false }) }, - // 文章列表 + // 文章列表:请求前不清空;仅当当前无文章可展示时打骨架标记 getArticleList() { + if (!this.infoList || this.infoList.length === 0) { + this.articleFetchingEmpty = true + } articleList({ method: "get", data: { @@ -125,12 +147,12 @@ cid: this.cid } }).then((res) => { - // console.log(res, 'list'); if (res.data.code == 0) { this.infoList = (res.data.result && res.data.result.list) || [] - // console.log(res, 'list') } - + }).finally(() => { + this.articleInitialLoading = false + this.articleFetchingEmpty = false }) } }, @@ -138,6 +160,7 @@ // this.getList(); }, onShow() { + // 不清空 categoryLists / infoList,成功后再替换 this.getList(); } } @@ -251,4 +274,4 @@ } } } - \ No newline at end of file + diff --git a/pages/home/home.vue b/pages/home/home.vue index d378eeb..cb5387d 100644 --- a/pages/home/home.vue +++ b/pages/home/home.vue @@ -49,7 +49,10 @@ - + + + + 更多 - - - - - - - - - {{item.name}} - {{item.title}} + + + @@ -243,32 +250,35 @@ - - - - + + @@ -332,6 +342,10 @@ export default { doctorList: [], productList: [], homeZones: [], + // 首次加载骨架标记:仅第一次请求结束前为 true,刷新时保留旧数据不闪空 + doctorListInitialLoading: true, + productListInitialLoading: true, + homeZonesInitialLoading: true, topProductsTitle: '热销商品', topProductsList: [], qualifications: [], @@ -683,7 +697,7 @@ export default { }) }, getList() { - this.doctorList = []; + // 不再请求前清空 doctorList,避免 onShow 刷新时出现「先空后满」 doctorList({ method: "post", data: { store_id: uni.getStorageSync('store_id') || '11001' } @@ -699,6 +713,8 @@ export default { const responseData = isGuest && res.data.result ? res.data.result : res.data.data; this.doctorList = (responseData.list || []).slice(0, 5) } + }).finally(() => { + this.doctorListInitialLoading = false }) }, toChang(e) { @@ -750,6 +766,9 @@ export default { if (res.data.code == 0) { this.productList = res.data.result.records; } + }).finally(() => { + // top 与 product 任一返回即可结束首次骨架(见 getHomeTopProducts) + this._markProductInitialLoaded() }) }, getHomeTopProducts() { @@ -761,8 +780,17 @@ export default { this.topProductsTitle = result.title || '热销商品'; this.topProductsList = result.list || []; } + }).finally(() => { + this._markProductInitialLoaded() }) }, + // 商品区依赖 topProducts 或 productList,两者都结束首次请求后再关骨架 + _markProductInitialLoaded() { + this._productFetchDone = (this._productFetchDone || 0) + 1 + if (this._productFetchDone >= 2) { + this.productListInitialLoading = false + } + }, getHomeZones() { getHomeZonesApi({ store_id: uni.getStorageSync('store_id') || '11001', @@ -770,7 +798,14 @@ export default { if (res.data.code == 0) { this.homeZones = res.data.result || res.data.data || []; } - }).catch(() => { this.homeZones = []; }) + }).catch(() => { + // 仅尚无数据时清空,避免刷新失败抹掉已有专区 + if (!this.homeZones || this.homeZones.length === 0) { + this.homeZones = []; + } + }).finally(() => { + this.homeZonesInitialLoading = false + }) }, getPlatformQualifications() { getPlatformQualificationsApi({ @@ -965,12 +1000,12 @@ $card-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.04); &:active { transform: scale(0.95); } .zone-icon-box { width: 100rpx; height: 100rpx; - background: linear-gradient(180deg, #FFFFFF 0%, #F5F9FF 100%); + //background: linear-gradient(180deg, #FFFFFF 0%, #F5F9FF 100%); border-radius: 36rpx; display: flex; align-items: center; justify-content: center; - box-shadow: 0 8rpx 24rpx rgba(65, 117, 238, 0.08); + //box-shadow: 0 8rpx 24rpx rgba(65, 117, 238, 0.08); margin-bottom: 12rpx; - .zone-img { width: 56rpx; height: 56rpx; } + .zone-img { width: 100rpx; height: 100rpx; } } .zone-text { font-size: 24rpx; color: #555; font-weight: 500; } } diff --git a/pages/index/index.vue b/pages/index/index.vue index 4f34095..60744bb 100644 --- a/pages/index/index.vue +++ b/pages/index/index.vue @@ -12,111 +12,118 @@ - - - - - - + + + 在线咨询 + + @@ -136,7 +143,6 @@

{{ item.last_message }}

-
暂无新消息 @@ -167,7 +173,6 @@

{{ item.last_message }}

-
暂无新消息 @@ -203,7 +208,10 @@ export default { imChat: [], onlineConsultationList: [], // 在线咨询列表 (type=1) onlineReconsultationList: [], // 在线复诊列表 (type=3) - type: "" + type: "", + // 首次加载骨架:刷新时不再清空数据,故仅首次为 true + noticeInitialLoading: true, + chatInitialLoading: true } }, onLoad() { @@ -235,7 +243,6 @@ export default { type: "all" } }).then((res) => { - // console.log(res, 'res'); uni.showToast({ title: '清除成功', duration: 2000, @@ -257,7 +264,6 @@ export default { type: "system" } }).then((res) => { - // console.log(res, 'res'); }) }, // 详情 @@ -272,7 +278,6 @@ export default { type: "order" } }).then((res) => { - // console.log(res, 'res'); }) }, goDoctor() { @@ -286,7 +291,6 @@ export default { type: "doctor_news" } }).then((res) => { - // console.log(res, 'res'); }) }, // 转诊消息 @@ -299,17 +303,8 @@ export default { uni.navigateTo({ url: `/subPackages/chat/chat?room_id=${item.room_id}&nick_name=${item.nick_name}&user_id=${item.id}&avatar=${encodeURIComponent(item.avatar || '')}&room_status=${item.room_status || 0}` }) - // getseesionRead({ - // method: "post", - // data: { - // store_id: uni.getStorageSync('store_id') || 11001, - // type: "doctor_news" - // } - // }).then((res) => { - // // console.log(res, 'res'); - // }) }, - // + // 获取系统/订单/医生消息概览;成功才覆盖,失败保留旧数据 async getInfo() { try { const res = await getSystemNoticeIndexApi({ @@ -322,8 +317,11 @@ export default { } } catch (error) { console.error('获取消息概览失败:', error) + } finally { + this.noticeInitialLoading = false } }, + // 拉取 IM 好友会话列表,成功后整份替换,失败不写空 getMyChatFriendsList() { getChatFriendsListApi().then((res) => { const allChatList = res.data.result || [] @@ -332,9 +330,13 @@ export default { this.onlineReconsultationList = allChatList.filter(item => (item.type === 3 || item.type === 2)) // type=3的为在线复诊 // 保持imChat兼容性(包含所有) this.imChat = allChatList + }).catch((error) => { + console.error('获取聊天列表失败:', error) + }).finally(() => { + this.chatInitialLoading = false }) }, - // 获取转诊消息列表 + // 获取转诊消息列表;失败不重置 transfer,避免闪空 getTransferList() { getTransferPrescriptionListApi().then((res) => { if (res.data && res.data.code == 0) { @@ -362,15 +364,7 @@ export default { this.getInfo() }, onShow() { - this.order = [] - this.system = [] - this.doctor = [] - this.transfer = { - num: 0, - time: '' - } - this.onlineConsultationList = [] - this.onlineReconsultationList = [] + // 不再清空列表:保留旧 UI,接口成功后再覆盖 this.getInfo() this.getMyChatFriendsList() this.getTransferList() @@ -493,4 +487,4 @@ export default { } } - \ No newline at end of file + diff --git a/request/api/api.js b/request/api/api.js index c0e4a18..467e032 100644 --- a/request/api/api.js +++ b/request/api/api.js @@ -515,10 +515,10 @@ export async function postRefundBased(params) { // return data // } +/** 物流详情 → xk-api mobile(含多包裹 packages) */ export async function getLogistics(params) { - let data = await http('/oldApi/v1/product-order/express', params) - return data - + let data = await http('/xkApi/product-order/express', params) + return normalizeXkApiResponse(data) } diff --git a/request/api/request.js b/request/api/request.js index 9a68926..41b488c 100644 --- a/request/api/request.js +++ b/request/api/request.js @@ -80,17 +80,21 @@ const isDev = checkDev('dev'); const ENV_URLS = { // 生产环境 prod: { - main: "https://app.xiaokang88.com/member", - xkApi: "https://api.xiaokang88.com/api/mobile", - shop: "https://shop.xiaokang88.com/member", - im: "https://api.ws.g.xiaokang88.com/api" + // main: "https://app.xiaokang88.com/member", + // xkApi: "https://api.xiaokang88.com/api/mobile", + // shop: "https://shop.xiaokang88.com/member", + // im: "https://api.ws.g.xiaokang88.com/api", + main: "https://xk.test.saas.api-yii.nailaoyun.cn/member", + xkApi: "https://xk.test.saas.api.nailaoyun.cn/api/mobile", + shop: "https://test.xk.shop.nailaoyun.cn/member1", + im: "https://xk.ws.nailaoyun.cn/api" }, // 测试环境 test: { - main: "https://test.xk.app.nailaoyun.cn/member", - xkApi: "https://test.xk.api.nailaoyun.cn/api/mobile", - shop: "https://test.xk.shop.nailaoyun.cn/member", - im: "https://api.ws.g.nailaoyun.cn/api" + main: "http://xk.test.saas.api.yii.nailaoyun.cn/member", + xkApi: "http://xk.test.saas.api.nailaoyun.cn/api/mobile", + shop: "https://test.xk.shop.nailaoyun.cn/member1", + im: "" }, // 本地开发环境 local: { diff --git a/subPackages/chat/chat.vue b/subPackages/chat/chat.vue index 8cc7dab..3895728 100644 --- a/subPackages/chat/chat.vue +++ b/subPackages/chat/chat.vue @@ -59,15 +59,17 @@ > - + - + {{ part.text }} - - 《{{ part.text }}》 - - + 《{{ part.text }}》 + @@ -103,6 +105,7 @@ @goToOrderMedicine="goToOrderMedicine" @viewOrder="viewOrder" @followUpDrugAnswered="onFollowUpDrugAnswered" + @avatarClick="goDoctorDetail(msg)" /> @@ -115,9 +118,10 @@
- + ag.name === agreementName || ag.name.includes(agreementName)); - + const agreement = this.findAgreementByName(agreementName); + if (agreement) { - // 找到匹配的协议,添加为可点击的链接 parts.push({ type: 'agreement', text: agreementName, agreementId: agreement.id }); } else { - // 没找到匹配的协议,作为普通文本显示 parts.push({ type: 'text', - text: match[0] // 《协议名称》 + text: match[0] }); } @@ -527,6 +543,24 @@ export default { this.parsedNoticeParts = parts; }, + /** + * 按文案中的协议名匹配协议列表(放宽 name/desc 双向包含) + * 匹配不到但列表非空时回退第一条,避免《》无法点击 + */ + findAgreementByName(agreementName) { + const list = this.agreements || []; + if (!list.length) return null; + const name = String(agreementName || ''); + const hit = list.find((ag) => { + const n = String(ag.name || ''); + const d = String(ag.desc || ''); + return n === name + || n.includes(name) + || name.includes(n) + || (d && (d === name || d.includes(name) || name.includes(d))); + }); + return hit || list[0]; + }, // 查看协议 async viewAgreement(agreementId) { if (!agreementId) { @@ -541,6 +575,7 @@ export default { const agreement = this.agreements.find(ag => ag.id === agreementId); this.currentAgreementId = agreementId; this.currentAgreementName = agreement ? agreement.name : '协议详情'; + this.agreementContent = ''; this.showAgreement = true; try { @@ -569,6 +604,26 @@ export default { this.showAgreement = false; }, + /** + * 点击气泡医生头像进入医生详情(只读,不展示挂号) + * 己方/助理头像不跳转 + */ + goDoctorDetail(msg) { + if (!msg || this.isMsgMine(msg)) return; + const senderId = msg.sender_user_id || msg.senderId || ''; + if (senderId === 'doctor_assistant') return; + const cleanId = String(this.doctorId || '') + .replace(/^doctor-/, '') + .replace(/-type-.*$/, ''); + if (!cleanId) { + uni.showToast({ title: '医生信息缺失', icon: 'none' }); + return; + } + uni.navigateTo({ + url: `/subPackages/doctor/doctor-detail?id=${cleanId}&readonly=1` + }); + }, + // === 音频播放逻辑 === playAudio(msg) { const audioUrl = getParsedContent(msg.message_type, msg.message_content); diff --git a/subPackages/chat/components/MessageBubble.vue b/subPackages/chat/components/MessageBubble.vue index d89abc2..dd9e7b8 100644 --- a/subPackages/chat/components/MessageBubble.vue +++ b/subPackages/chat/components/MessageBubble.vue @@ -1,7 +1,7 @@ \ No newline at end of file + diff --git a/utils/content-security.js b/utils/content-security.js index 1fb0664..a680468 100644 --- a/utils/content-security.js +++ b/utils/content-security.js @@ -84,13 +84,11 @@ export function handleSecurityError(err) { function assertCheckOk(res) { - if (res && res.code === 0) { - + if (res && res.data.code === 0) { return true; - } - throw { msg: (res && res.message) || '内容含有违规信息,请修改后重试' }; + throw { msg: (res && res.data.message) || '内容含有违规信息,请修改后重试' }; } diff --git a/utils/formatStoreNameWithHu.js b/utils/formatStoreNameWithHu.js new file mode 100644 index 0000000..81246eb --- /dev/null +++ b/utils/formatStoreNameWithHu.js @@ -0,0 +1,12 @@ +/** + * 在线处方诊所名追加「(互)」:is_online 为 1/2/3,线下 0 不加 + */ +export function formatStoreNameWithHu(storeName, isOnline) { + const name = String(storeName || '').trim() + if (!name) return '' + const online = Number(isOnline) + if (online === 1 || online === 2 || online === 3) { + return name.endsWith('(互)') ? name : `${name}(互)` + } + return name +} diff --git a/utils/utils.js b/utils/utils.js index d0b9d95..62c7394 100644 --- a/utils/utils.js +++ b/utils/utils.js @@ -6,8 +6,8 @@ export function checkDev(key = '') { // 判断某些模块是否开启 switch (key) { case 'dev': - // return false; - return true; + return false; + // return true; case 'open-im': // return false; return true; diff --git a/utils/ws/websocket.js b/utils/ws/websocket.js index 0a13ba9..18e6bae 100644 --- a/utils/ws/websocket.js +++ b/utils/ws/websocket.js @@ -331,5 +331,6 @@ export class WebSocketManager { // 全局WebSocket实例 // export const webSocketManager = new WebSocketManager('ws://127.0.0.1:12080/ws'); -const wsUrl = checkDev('dev')? 'ws://127.0.0.1:12080/ws':'wss://api.ws.g.xiaokang88.com/ws'; +// const wsUrl = checkDev('dev')? 'ws://127.0.0.1:12080/ws':'wss://api.ws.g.xiaokang88.com/ws'; +const wsUrl = checkDev('dev')? 'ws://127.0.0.1:12080/ws':'wss://xk.ws.nailaoyun.cn/ws'; export const webSocketManager = new WebSocketManager(wsUrl); \ No newline at end of file