Compare commits

...

5 Commits

Author SHA1 Message Date
iven
8ad4329632 chore(mp): 配置优化 + 文档更新
- config: virtualHost + native-components 拷贝配置
- project.config: skylineRenderEnable=false 调试用
- app.config: 移除 lazyCodeLoading 注释(已在 config/index.ts 控制)
- dev.ps1: WECHAT_DEV_MODE=false(真机测试用)
- wiki: 更新 DevTools 卡死根因 + 构建模式说明
- CLAUDE.md: 添加 graphify 知识图谱规则
2026-05-25 13:45:46 +08:00
iven
1a376a255d fix(mp): 导航/请求健壮性 — reLaunch 去重 + 失败降级
- navigateToLogin 添加去重 + reLaunch 失败降级 redirectTo
- request.ts safeReLaunch 添加目标页检测 + 失败降级
- 退出登录 reLaunch 失败降级 redirectTo
- DoctorTabBar / 首页医生端跳转 reLaunch 失败降级
- 网络恢复时正确清理 toast 状态和定时器
2026-05-25 13:45:12 +08:00
iven
485b9bb926 feat(mp): 登录页 UX 优化 — 协议区域就近显示
- 协议勾选移至对应操作区域(微信登录 + 绑定区)更直觉
- 统一 SHOW_DEV_LOGIN 常量控制开发模式入口
- 抽取 requireAgreement() 复用协议检查逻辑
2026-05-25 13:44:35 +08:00
iven
185f411495 feat(mp): 文章详情页改用 mp-html 原生富文本组件
- 引入 mp-html 替代 RichText,支持图文混排、表格等复杂内容
- 新建 RichArticle 组件封装 sanitizeHtml + mp-html
- 通过 native-components 拷贝原生组件到 dist
- 优化文章排版样式(字号、间距、分隔线、底栏安全区)
- sanitize-html 扩展允许 style/data-w-e-type 属性
2026-05-25 13:44:00 +08:00
iven
a24c18155f feat(mp): BLE 血氧仪支持 + 服务发现增强
- 新增 Pulse Oximeter Service (0x1822) 支持,含 SFLOAT 解析
- 连接后自动扫描全部服务,发现并订阅已知健康 UUID
- 设备同步页展示已发现的服务和可用数据类型标签
- 新增 BLEDiscoveredService / BLEDiscoveredCharacteristic 类型
2026-05-25 13:43:16 +08:00
35 changed files with 608 additions and 104 deletions

View File

@@ -504,3 +504,13 @@ chore(docker): 添加 PostgreSQL 健康检查
| 设计文档索引 | `wiki/index.md` |
| 开发进度、模块状态 | `wiki/index.md` 关键数字 |
| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 |
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@@ -44,6 +44,13 @@ export default defineConfig(async (merge) => {
resource: ['src/styles/variables.scss'],
},
mini: {
virtualHost: true,
copy: {
patterns: [
{ from: 'src/native-components/', to: 'dist/native-components/', ignore: ['*.ts'] },
],
options: {},
},
compile: {
exclude: [],
include: [],

View File

@@ -34,6 +34,7 @@
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"mp-html": "^2.5.2",
"react": "^18.3.0",
"react-dom": "18.3.1",
"zustand": "^5.0.0"

View File

@@ -38,6 +38,9 @@ importers:
'@tarojs/taro':
specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.28)(html-webpack-plugin@5.6.7(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(postcss@8.5.12)(rollup@3.30.0)(webpack-chain@6.5.1)(webpack-dev-server@4.15.2(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7)))(webpack@5.95.0(@swc/core@1.3.96)(esbuild@0.27.7))
mp-html:
specifier: ^2.5.2
version: 2.5.2
react:
specifier: ^18.3.0
version: 18.3.1
@@ -4435,6 +4438,9 @@ packages:
mobile-detect@1.4.5:
resolution: {integrity: sha512-yc0LhH6tItlvfLBugVUEtgawwFU2sIe+cSdmRJJCTMZ5GEJyLxNyC/NIOAOGk67Fa8GNpOttO3Xz/1bHpXFD/g==}
mp-html@2.5.2:
resolution: {integrity: sha512-45e8c32Qgux4YU4iC3qCSFsOh3y+RwPwZ+iz/vvLkDgSGWk+1zsL4WUzWWQc9w3AsAfkaD/QR0oIufIDngBmXA==}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -11011,6 +11017,8 @@ snapshots:
mobile-detect@1.4.5: {}
mp-html@2.5.2: {}
ms@2.0.0: {}
ms@2.1.3: {}

View File

@@ -14,7 +14,8 @@
"minifyWXML": true,
"packNpmManually": false,
"packNpmRelationList": [],
"ignoreUploadUnusedFiles": true
"ignoreUploadUnusedFiles": true,
"skylineRenderEnable": false
},
"condition": {}
}

View File

@@ -1,5 +1,4 @@
export default defineAppConfig({
// 仅生产构建启用dev 模式下 lazyCodeLoading 导致 DevTools / 真机调试卡死
...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}),
pages: [
'pages/index/index',

View File

@@ -0,0 +1,32 @@
import { memo, useMemo } from 'react';
import { View } from '@tarojs/components';
import { sanitizeHtml } from '@/utils/sanitize-html';
interface RichArticleProps {
html: string;
className?: string;
}
function prepareHtml(raw: string): string {
return sanitizeHtml(raw);
}
function RichArticle({ html, className }: RichArticleProps) {
const content = useMemo(() => prepareHtml(html), [html]);
if (!content) return null;
return (
<View className={className}>
<mp-html
content={content}
lazy-load
selectable
container-style="font-size:16px;color:#5A554F;line-height:1.8;word-break:break-word"
tag-style='{"img":"max-width:100%;border-radius:8px;margin:12px auto;display:block","a":"color:#C4623A;text-decoration:none"}'
/>
</View>
);
}
export default memo(RichArticle);

View File

@@ -27,7 +27,9 @@ export default function DoctorTabBar({ active }: DoctorTabBarProps) {
const handleTab = (tab: TabItem) => {
if (tab.key === activeKey) return;
Taro.reLaunch({ url: tab.url });
Taro.reLaunch({ url: tab.url }).catch(() => {
Taro.redirectTo({ url: tab.url }).catch(() => {});
});
};
return (

View File

@@ -0,0 +1,8 @@
"use strict";function e(t){"@babel/helpers - typeof";return(e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(t)}function t(e,t,o){return(t=n(t))in e?Object.defineProperty(e,t,{value:o,enumerable:!0,configurable:!0,writable:!0}):e[t]=o,e}function n(t){var n=o(t,"string");return"symbol"==e(n)?n:n+""}function o(t,n){if("object"!=e(t)||!t)return t;var o=t[Symbol.toPrimitive];if(void 0!==o){var i=o.call(t,n||"default");if("object"!=e(i))return i;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===n?String:Number)(t)}/*!
* mp-html v2.5.2
* https://github.com/jin-yufeng/mp-html
*
* Released under the MIT license
* Author: Jin Yufeng
*/
var i=require("./parser"),r=[];Component({data:{nodes:[]},properties:{containerStyle:String,content:{type:String,value:"",observer:function(e){this.setContent(e)}},copyLink:{type:Boolean,value:!0},domain:String,errorImg:String,lazyLoad:Boolean,loadingImg:String,pauseVideo:{type:Boolean,value:!0},previewImg:{type:null,value:!0},scrollTable:Boolean,selectable:null,setTitle:{type:Boolean,value:!0},showImgMenu:{type:Boolean,value:!0},tagStyle:Object,useAnchor:null},created:function(){this.plugins=[];for(var e=r.length;e--;)this.plugins.push(new r[e](this))},detached:function(){this._hook("onDetached")},methods:{in:function(e,t,n){e&&t&&n&&(this._in={page:e,selector:t,scrollTop:n})},navigateTo:function(e,n){var o=this;return new Promise(function(i,r){if(!o.data.useAnchor)return void r(Error("Anchor is disabled"));var a=wx.createSelectorQuery().in(o._in?o._in.page:o).select((o._in?o._in.selector:"._root")+(e?"".concat(">>>","#").concat(e):"")).boundingClientRect();o._in?a.select(o._in.selector).scrollOffset().select(o._in.selector).boundingClientRect():a.selectViewport().scrollOffset(),a.exec(function(e){if(!e[0])return void r(Error("Label not found"));var a=e[1].scrollTop+e[0].top-(e[2]?e[2].top:0)+(n||parseInt(o.data.useAnchor)||0);o._in?o._in.page.setData(t({},o._in.scrollTop,a)):wx.pageScrollTo({scrollTop:a,duration:300}),i()})})},getText:function(e){var t="";return function e(n){for(var o=0;o<n.length;o++){var i=n[o];if("text"===i.type)t+=i.text.replace(/&amp;/g,"&");else if("br"===i.name)t+="\n";else{var r="p"===i.name||"div"===i.name||"tr"===i.name||"li"===i.name||"h"===i.name[0]&&i.name[1]>"0"&&i.name[1]<"7";r&&t&&"\n"!==t[t.length-1]&&(t+="\n"),i.children&&e(i.children),r&&"\n"!==t[t.length-1]?t+="\n":"td"!==i.name&&"th"!==i.name||(t+="\t")}}}(e||this.data.nodes),t},getRect:function(){var e=this;return new Promise(function(t,n){wx.createSelectorQuery().in(e).select("._root").boundingClientRect().exec(function(e){return e[0]?t(e[0]):n(Error("Root label not found"))})})},pauseMedia:function(){for(var e=(this._videos||[]).length;e--;)this._videos[e].pause()},setPlaybackRate:function(e){this.playbackRate=e;for(var t=(this._videos||[]).length;t--;)this._videos[t].playbackRate(e)},setContent:function(e,t){var n=this;this.imgList&&t||(this.imgList=[]),this._videos=[];var o={},r=new i(this).parse(e);if(t)for(var a=this.data.nodes.length,s=r.length;s--;)o["nodes[".concat(a+s,"]")]=r[s];else o.nodes=r;if(this.setData(o,function(){n._hook("onLoad"),n.triggerEvent("load")}),this.data.lazyLoad||this.imgList._unloadimgs<this.imgList.length/2){var l=0,c=function(e){e&&e.height||(e={}),e.height===l?n.triggerEvent("ready",e):(l=e.height,setTimeout(function(){n.getRect().then(c).catch(c)},350))};this.getRect().then(c).catch(c)}else this.imgList._unloadimgs||this.getRect().then(function(e){n.triggerEvent("ready",e)}).catch(function(){n.triggerEvent("ready",{})})},_hook:function(e){for(var t=r.length;t--;)this.plugins[t][e]&&this.plugins[t][e]()},_add:function(e){e.detail.root=this}}});

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node/node"}}

View File

@@ -0,0 +1 @@
<view class="_root {{selectable?'_select':''}}" style="{{containerStyle}}"><slot wx:if="{{!nodes[0]}}"/><node id="_root" childs="{{nodes}}" opts="{{[lazyLoad,loadingImg,errorImg,showImgMenu,selectable]}}" catchadd="_add"/></view>

View File

@@ -0,0 +1 @@
._root{padding:1px 0;overflow-x:auto;overflow-y:hidden;-webkit-overflow-scrolling:touch}._select{-webkit-user-select:text;user-select:text}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"component":true,"usingComponents":{"node":"./node"}}

View File

@@ -0,0 +1 @@
<wxs module="isInline">var e={abbr:!0,b:!0,big:!0,code:!0,del:!0,em:!0,i:!0,ins:!0,label:!0,q:!0,small:!0,span:!0,strong:!0,sub:!0,sup:!0};module.exports=function(n,i){return e[n]||-1!==(i||"").indexOf("inline")};</wxs><template name="el"><block wx:if="{{n.name==='img'}}"><rich-text wx:if="{{n.t}}" style="display:{{n.t}}" nodes="<img class='_img' style='{{n.attrs.style}}' src='{{n.attrs.src}}'>" data-i="{{i}}" catchtap="imgTap"/><block wx:else><image wx:if="{{(opts[1]&&!ctrl[i])||ctrl[i]<0}}" class="_img" style="{{n.attrs.style}}" src="{{ctrl[i]<0?opts[2]:opts[1]}}" mode="widthFix"/><image id="{{n.attrs.id}}" class="_img {{n.attrs.class}}" style="{{ctrl[i]===-1?'display:none;':''}}width:{{ctrl[i]||1}}px;height:1px;{{n.attrs.style}}" src="{{n.attrs.src}}" mode="{{!n.h?'widthFix':(!n.w?'heightFix':(n.m||'scaleToFill'))}}" lazy-load="{{opts[0]}}" webp="{{n.webp}}" show-menu-by-longpress="{{opts[3]&&!n.attrs.ignore}}" data-i="{{i}}" bindload="imgLoad" binderror="mediaError" catchtap="imgTap" bindlongpress="noop"/></block></block><text wx:elif="{{n.text}}" user-select="{{opts[4]=='force'&&isiOS}}" decode>{{n.text}}</text><text wx:elif="{{n.name==='br'}}">{{'\n'}}</text><view wx:elif="{{n.name==='a'}}" id="{{n.attrs.id}}" class="{{n.attrs.href?'_a ':''}}{{n.attrs.class}}" hover-class="_hover" style="display:inline;{{n.attrs.style}}" data-i="{{i}}" catchtap="linkTap"><node childs="{{n.children}}" opts="{{opts}}" style="display:inherit"/></view><video wx:elif="{{n.name==='video'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" autoplay="{{n.attrs.autoplay}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" muted="{{n.attrs.muted}}" object-fit="{{n.attrs['object-fit']}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" bindfullscreenchange="mediaEvent" binderror="mediaError"/><audio wx:elif="{{n.name==='audio'}}" id="{{n.attrs.id}}" class="{{n.attrs.class}}" style="{{n.attrs.style}}" author="{{n.attrs.author}}" controls="{{n.attrs.controls}}" loop="{{n.attrs.loop}}" name="{{n.attrs.name}}" poster="{{n.attrs.poster}}" src="{{n.src[ctrl[i]||0]}}" data-i="{{i}}" bindplay="play" bindpause="mediaEvent" binderror="mediaError"/><rich-text wx:else id="{{n.attrs.id}}" style="{{n.f}}" user-select="{{opts[4]}}" nodes="{{[n]}}"/></template><block wx:for="{{nodes}}" wx:for-item="n1" wx:for-index="i1" wx:key="i1"><template wx:if="{{!n1.c&&(!n1.children||n1.name==='a'||!isInline(n1.name,n1.attrs.style))}}" is="el" data="{{n:n1,i:''+i1,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n1.attrs.id}}" class="_{{n1.name}} {{n1.attrs.class}}" style="{{n1.attrs.style}}"><block wx:for="{{n1.children}}" wx:for-item="n2" wx:for-index="i2" wx:key="i2"><template wx:if="{{!n2.c&&(!n2.children||n2.name==='a'||!isInline(n2.name,n2.attrs.style))}}" is="el" data="{{n:n2,i:i1+'_'+i2,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n2.attrs.id}}" class="_{{n2.name}} {{n2.attrs.class}}" style="{{n2.attrs.style}}"><block wx:for="{{n2.children}}" wx:for-item="n3" wx:for-index="i3" wx:key="i3"><template wx:if="{{!n3.c&&(!n3.children||n3.name==='a'||!isInline(n3.name,n3.attrs.style))}}" is="el" data="{{n:n3,i:i1+'_'+i2+'_'+i3,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n3.attrs.id}}" class="_{{n3.name}} {{n3.attrs.class}}" style="{{n3.attrs.style}}"><block wx:for="{{n3.children}}" wx:for-item="n4" wx:for-index="i4" wx:key="i4"><template wx:if="{{!n4.c&&(!n4.children||n4.name==='a'||!isInline(n4.name,n4.attrs.style))}}" is="el" data="{{n:n4,i:i1+'_'+i2+'_'+i3+'_'+i4,opts:opts,ctrl:ctrl}}"/><view wx:else id="{{n4.attrs.id}}" class="_{{n4.name}} {{n4.attrs.class}}" style="{{n4.attrs.style}}"><block wx:for="{{n4.children}}" wx:for-item="n5" wx:for-index="i5" wx:key="i5"><template wx:if="{{!n5.c&&(!n5.children||n5.name==='a'||!isInline(n5.name,n5.attrs.style))}}" is="el" data="{{n:n5,i:i1+'_'+i2+'_'+i3+'_'+i4+'_'+i5,opts:opts,ctrl:ctrl}}"/><node wx:else id="{{n5.attrs.id}}" class="_{{n5.name}} {{n5.attrs.class}}" style="{{n5.attrs.style}}" childs="{{n5.children}}" opts="{{opts}}"/></block></view></block></view></block></view></block></view></block>

View File

@@ -0,0 +1 @@
._a{padding:1.5px 0 1.5px 0;color:#366092;word-break:break-all}._hover{text-decoration:underline;opacity:.7}._img{max-width:100%;-webkit-touch-callout:none}._b,._strong{font-weight:700}._code{font-family:monospace}._del{text-decoration:line-through}._em,._i{font-style:italic}._h1{font-size:2em}._h2{font-size:1.5em}._h3{font-size:1.17em}._h5{font-size:.83em}._h6{font-size:.67em}._h1,._h2,._h3,._h4,._h5,._h6{display:block;font-weight:700}._ins{text-decoration:underline}._li{display:list-item}._ol{list-style-type:decimal}._ol,._ul{display:block;padding-left:40px;margin:1em 0}._q::before{content:'"'}._q::after{content:'"'}._sub{font-size:smaller;vertical-align:sub}._sup{font-size:smaller;vertical-align:super}._tbody,._tfoot,._thead{display:table-row-group}._tr{display:table-row}._td,._th{display:table-cell;vertical-align:middle}._th{font-weight:700;text-align:center}._ul{list-style-type:disc}._ul ._ul{margin:0;list-style-type:circle}._ul ._ul ._ul{list-style-type:square}._abbr,._b,._code,._del,._em,._i,._ins,._label,._q,._span,._strong,._sub,._sup{display:inline}._blockquote,._div,._p{display:block}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '文章详情',
usingComponents: {
'mp-html': '../../../native-components/mp-html/index',
},
});

View File

@@ -1,19 +1,21 @@
@import '../../../styles/variables.scss';
// 文章详情页 — 对齐原型 docs/design/mp-04-article-report.html → ArticleDetail
// 文章详情页 — 阅读优化排版
.article-detail-page {
padding-bottom: 80px;
padding-bottom: 100px;
background: $card;
}
.article-title {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2);
font-size: var(--tk-font-h1);
font-weight: 700;
color: $tx;
display: block;
line-height: 1.4;
margin-bottom: var(--tk-gap-sm);
line-height: 1.35;
margin-bottom: var(--tk-gap-md);
letter-spacing: 0.5px;
}
.article-meta {
@@ -21,36 +23,38 @@
gap: var(--tk-gap-md);
font-size: var(--tk-font-cap);
color: $tx3;
margin-bottom: var(--tk-gap-md);
margin-bottom: var(--tk-gap-lg);
align-items: center;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.meta-icon {
font-size: 13px;
line-height: 1;
}
.article-divider {
height: 1px;
background: $bd-l;
background: linear-gradient(90deg, $bd-l, $bd, $bd-l);
margin-bottom: var(--tk-section-gap);
}
.article-body {
font-size: 15px;
// RichText 内部样式由 formatArticleHtml 内联注入
// 这里只控制容器间距
font-size: var(--tk-font-body);
color: $tx2;
line-height: 1.8;
// RichText 内部样式
h1, h2, h3 {
font-weight: bold;
// 兜底:万一内联样式未生效的标签
h1, h2, h3, h4, h5, h6 {
color: $tx;
margin: var(--tk-gap-lg) 0 var(--tk-gap-sm);
}
p {
margin-bottom: var(--tk-gap-md);
text-indent: 2em;
}
img {
max-width: 100%;
border-radius: $r-sm;
margin: var(--tk-gap-sm) 0;
line-height: 1.4;
}
}
@@ -59,31 +63,40 @@
bottom: 0;
left: 0;
right: 0;
height: 60px;
height: 64px;
background: $card;
border-top: 1px solid $bd-l;
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
gap: 48px;
padding: 0 var(--tk-page-padding);
z-index: 10;
// 安全区适配
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -2px 12px rgba(45, 42, 38, 0.06);
}
.article-action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
gap: 4px;
min-width: $touch-min;
min-height: $touch-min;
justify-content: center;
border-radius: $r-sm;
transition: background var(--tk-duration-fast);
&:active {
background: $surface-alt;
opacity: var(--tk-touch-feedback-opacity);
}
}
.article-action-icon {
font-size: 20px;
color: $tx3;
font-size: 22px;
color: $tx2;
line-height: 1;
}
@@ -105,3 +118,29 @@
font-size: var(--tk-font-body);
color: $tx3;
}
// ─── 关怀模式覆盖 ───
.elder-mode {
.article-title {
font-size: var(--tk-font-h1);
line-height: 1.3;
}
.article-body {
font-size: var(--tk-font-body);
line-height: 2;
}
.article-bottom-bar {
height: 72px;
gap: 56px;
}
.article-action-icon {
font-size: 26px;
}
.article-action-text {
font-size: var(--tk-font-body-sm);
}
}

View File

@@ -1,13 +1,13 @@
import { useState, useCallback } from 'react';
import { View, Text, RichText } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter, useShareAppMessage } from '@tarojs/taro';
import { usePageData } from '@/hooks/usePageData';
import { getArticleDetail, getPublicArticleDetail, Article } from '../../../services/article';
import { trackEvent } from '@/services/analytics';
import { sanitizeHtml } from '@/utils/sanitize-html';
import { useElderClass } from '../../../hooks/useElderClass';
import { useAuthStore } from '../../../stores/auth';
import PageShell from '@/components/ui/PageShell';
import RichArticle from '@/components/RichArticle';
import './index.scss';
export default function ArticleDetail() {
@@ -77,14 +77,24 @@ export default function ArticleDetail() {
<Text className='article-title'>{article.title}</Text>
<View className='article-meta'>
{article.author && <Text>{article.author}</Text>}
{article.published_at && <Text>{article.published_at.slice(0, 10)}</Text>}
{article.author && (
<View className='meta-item'>
<Text className='meta-icon'></Text>
<Text>{article.author}</Text>
</View>
)}
{article.published_at && (
<View className='meta-item'>
<Text className='meta-icon'>📅</Text>
<Text>{article.published_at.slice(0, 10)}</Text>
</View>
)}
</View>
<View className='article-divider' />
<View className='article-body'>
<RichText nodes={sanitizeHtml(article.content || '')} />
<RichArticle html={article.content || ''} />
</View>
<View className='article-bottom-bar'>

View File

@@ -370,7 +370,8 @@ export default function Index() {
url: target,
fail: () => {
redirectingRef.current = false;
console.warn('跳转医生端失败,停留患者首页');
console.warn('跳转医生端失败,降级为 redirectTo');
Taro.redirectTo({ url: target }).catch(() => {});
},
});
}

View File

@@ -9,6 +9,7 @@ declare const __wxConfig: Record<string, unknown> | undefined;
const IS_DEV = process.env.NODE_ENV !== 'production';
const IS_SIMULATOR = typeof __wxConfig !== 'undefined' && (__wxConfig as Record<string, unknown>)?.envVersion === 'develop';
const SHOW_DEV_LOGIN = (IS_DEV || IS_SIMULATOR) && !!(process.env.TARO_APP_DEV_USER && process.env.TARO_APP_DEV_PASS);
export default function Login() {
const [agreed, setAgreed] = useState(false);
@@ -40,11 +41,16 @@ export default function Login() {
}
};
const handleWechatLogin = async () => {
const requireAgreement = () => {
if (!agreed) {
Taro.showToast({ title: '请先阅读并同意用户协议', icon: 'none' });
return;
return false;
}
return true;
};
const handleWechatLogin = async () => {
if (!requireAgreement()) return;
try {
const { code } = await Taro.login();
const result = await login(code);
@@ -60,23 +66,6 @@ export default function Login() {
}
};
const handleDevQuickLogin = async () => {
const devUser = process.env.TARO_APP_DEV_USER || '';
const devPass = process.env.TARO_APP_DEV_PASS || '';
if (!devUser || !devPass) {
Taro.showToast({ title: '未配置开发账号', icon: 'none' });
return;
}
try {
const success = await credentialLogin(devUser, devPass);
if (success) {
navigateAfterLogin();
}
} catch (err: unknown) {
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
}
};
const handleGetPhone = async (e: { detail: { errMsg: string; encryptedData: string; iv: string } }) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
Taro.showToast({ title: '需要授权手机号', icon: 'none' });
@@ -100,6 +89,7 @@ export default function Login() {
};
// DevTools 中 getPhoneNumber 不可用,直接传 mock 数据绕过微信 SDK
// 仅在后端 wechat_dev_mode=true 时有效,后端会生成 mock 手机号
const handleDevBindPhone = async () => {
try {
const success = await bindPhone('dev_mock', 'dev_mock');
@@ -117,6 +107,21 @@ export default function Login() {
}
};
const handleDevQuickLogin = async () => {
if (!requireAgreement()) return;
const devUser = process.env.TARO_APP_DEV_USER || '';
const devPass = process.env.TARO_APP_DEV_PASS || '';
if (!devUser || !devPass) return;
try {
const success = await credentialLogin(devUser, devPass);
if (success) {
navigateAfterLogin();
}
} catch (err: unknown) {
Taro.showToast({ title: err instanceof Error ? err.message : '登录失败', icon: 'none' });
}
};
return (
<View className="login-page">
{/* 品牌区 */}
@@ -130,16 +135,32 @@ export default function Login() {
{!needBind ? (
<>
{/* 微信一键登录 */}
{/* 微信一键登录(主按钮) */}
<View className="login-wechat-btn" onClick={handleWechatLogin}>
<Text className="login-wechat-icon"></Text>
<Text className="login-wechat-text"></Text>
</View>
{/* 协议 */}
<View className="agreement-row">
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
</>
) : (
<View className="login-bind-section">
{/* 真机:微信手机号授权 */}
{!(IS_DEV || IS_SIMULATOR) && (
{!SHOW_DEV_LOGIN && (
<Button
className="login-btn-bind"
openType="getPhoneNumber"
@@ -151,7 +172,7 @@ export default function Login() {
</Button>
)}
{/* DevTools跳过微信 SDK 直接调后端(后端 wechat_dev_mode 会用 mock 手机号) */}
{(IS_DEV || IS_SIMULATOR) && (
{SHOW_DEV_LOGIN && (
<Button
className="login-btn-bind"
onClick={handleDevBindPhone}
@@ -161,29 +182,28 @@ export default function Login() {
</Button>
)}
{/* 协议 */}
<View className="agreement-row" style={{ marginTop: '16px' }}>
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
</View>
)}
{/* 协议 */}
<View className="agreement-row">
<View
className={`agreement-check ${agreed ? 'checked' : ''}`}
onClick={() => setAgreed(!agreed)}
>
{agreed && <Text className="agreement-check-mark">&#10003;</Text>}
</View>
<Text className="agreement-text">
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/user-agreement')}></Text>
<Text className="agreement-link" onClick={() => safeNavigateTo('/pages/legal/privacy-policy')}></Text>
</Text>
</View>
<View style={{ flex: 1 }} />
{/* 开发模式 */}
{(IS_DEV || IS_SIMULATOR) && (
{/* 开发模式快速登录 — 仅 dev 构建 + DevTools 中显示 */}
{SHOW_DEV_LOGIN && (
<View className="login-dev-btn" onClick={handleDevQuickLogin}>
<Text className="login-dev-btn-text"> </Text>
</View>

View File

@@ -927,3 +927,62 @@
font-size: var(--tk-font-body-lg);
color: $tx2;
}
// ─── 服务发现信息 ───
.ds-services-info {
margin-bottom: var(--tk-gap-md) !important;
}
.ds-services-info__title {
display: block;
font-size: var(--tk-font-body-sm);
font-weight: 600;
color: $tx;
margin-bottom: var(--tk-gap-sm);
}
.ds-services-info__caps {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ds-cap-tag {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: $r-xs;
font-size: var(--tk-font-cap);
&--on {
background: rgba($acc, 0.08);
color: $acc;
}
&--off {
background: $surface-alt;
color: $tx3;
}
}
.ds-cap-tag__dot {
font-size: 10px;
}
.ds-cap-tag__text {
font-size: var(--tk-font-cap);
}
.ds-services-info__hint {
margin-top: var(--tk-gap-sm);
background: $wrn-l;
border-radius: $r-xs;
padding: 8px 12px;
}
.ds-services-info__hint-text {
font-size: var(--tk-font-cap);
color: $wrn;
line-height: 1.5;
}

View File

@@ -10,7 +10,7 @@ import { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter } from '@/service
import { DataSyncScheduler } from '@/services/ble/DataSyncScheduler';
import { uploadReadings } from '@/services/device-sync';
import { useAuthStore } from '@/stores/auth';
import type { BLEDevice, NormalizedReading } from '@/services/ble/types';
import type { BLEDevice, NormalizedReading, BLEDiscoveredService } from '@/services/ble/types';
import { useElderClass } from '@/hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import ContentCard from '@/components/ui/ContentCard';
@@ -80,6 +80,7 @@ export default function DeviceSync() {
const [errorMsg, setErrorMsg] = useState('');
const [lastSyncAt, setLastSyncAt] = useState<number | null>(null);
const [pendingCount, setPendingCount] = useState(0);
const [discoveredServices, setDiscoveredServices] = useState<BLEDiscoveredService[]>([]);
const scheduler = useMemo(() => new DataSyncScheduler({ intervalMs: 60 * 60 * 1000 }), []);
@@ -163,6 +164,8 @@ export default function DeviceSync() {
setErrorMsg('');
try {
await getBleManager().connect(device);
const conn = getBleManager().getConnection();
setDiscoveredServices(conn?.discoveredServices ?? []);
setPageState('connected');
} catch (e: unknown) {
setErrorMsg(e instanceof Error ? e.message : '连接失败');
@@ -217,6 +220,7 @@ export default function DeviceSync() {
setLiveReadings([]);
setSyncCount(0);
setErrorMsg('');
setDiscoveredServices([]);
}, []);
const latestReading = liveReadings.length > 0 ? liveReadings[liveReadings.length - 1] : null;
@@ -359,6 +363,54 @@ export default function DeviceSync() {
</View>
);
/** 渲染 BLE 服务发现信息 */
const renderServiceDiscovery = () => {
if (discoveredServices.length === 0) return null;
// 检查各类健康数据是否在已发现的 UUID 中可用
const hasCharShort = (short: string) =>
discoveredServices.some((s) =>
s.characteristics.some((c) =>
c.uuid.toUpperCase().replace(/-/g, '').slice(-4) === short,
),
);
const capabilities = [
{ key: '2A37', label: '心率', available: hasCharShort('2A37') },
{ key: '2A5F', label: '血氧(实时)', available: hasCharShort('2A5F') },
{ key: '2A5E', label: '血氧(单次)', available: hasCharShort('2A5E') },
{ key: '2A1C', label: '体温', available: hasCharShort('2A1C') },
{ key: '2A35', label: '血压', available: hasCharShort('2A35') },
];
const availableCount = capabilities.filter((c) => c.available).length;
return (
<ContentCard variant="outlined" padding="md" margin="none" className="ds-services-info">
<Text className="ds-services-info__title">
({discoveredServices.length} , {availableCount} )
</Text>
<View className="ds-services-info__caps">
{capabilities.map((cap) => (
<View key={cap.key} className={`ds-cap-tag ${cap.available ? 'ds-cap-tag--on' : 'ds-cap-tag--off'}`}>
<Text className="ds-cap-tag__dot">{cap.available ? '●' : '○'}</Text>
<Text className="ds-cap-tag__text">{cap.label}</Text>
</View>
))}
</View>
{availableCount <= 1 && (
<View className="ds-services-info__hint">
<Text className="ds-services-info__hint-text">
{selectedDevice?.name?.includes('HUAWEI') || selectedDevice?.name?.includes('HW')
? '华为手环的睡眠/步数/压力数据使用私有协议,需要华为运动健康 App 同步'
: '此设备仅暴露少量标准健康服务,更多数据请使用设备官方 App 同步'}
</Text>
</View>
)}
</ContentCard>
);
};
const renderLatestReading = () => {
if (!latestReading) return null;
return (
@@ -525,6 +577,7 @@ export default function DeviceSync() {
{pageState === 'connected' && (
<View className="ds-body">
{renderConnectedStatus()}
{renderServiceDiscovery()}
{renderLatestReading()}
{renderReadingsHistory()}
{renderConnectedActions()}

View File

@@ -7,6 +7,8 @@ import type {
BLEConnectionChangeResult,
BLECharacteristicChangeResult,
BLEServiceItem,
BLEDiscoveredService,
BLEDiscoveredCharacteristic,
} from './types';
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
@@ -121,10 +123,24 @@ export class BLEConnection {
}
}
/** 发现服务并启用通知 */
/** 已知的健康相关 Characteristic UUID用于自动发现和订阅 */
private static readonly HEALTH_CHAR_UUIDS: Record<string, string> = {
'2A37': 'heart_rate', // Heart Rate Measurement
'2A38': 'heart_rate_loc', // Body Sensor Location
'2A1C': 'temperature', // Temperature Measurement
'2A35': 'blood_pressure', // Blood Pressure Measurement
'2A5F': 'blood_oxygen', // PLX Continuous Measurement
'2A5E': 'blood_oxygen_spot',// PLX Spot-Check Measurement
};
/** 发现服务并启用通知 — 先订阅适配器指定的,再扫描全部服务尝试自动发现 */
private async discoverServices(device: BLEDevice): Promise<void> {
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
const services = servicesRes.services || [];
const discoveredServices: BLEDiscoveredService[] = [];
// ── 第一轮:订阅适配器预定义的 Characteristic保持向后兼容 ──
const subscribedCharUUIDs = new Set<string>();
for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) {
const svc = services.find((s: BLEServiceItem) =>
@@ -137,13 +153,90 @@ export class BLEConnection {
serviceId: svc.uuid,
});
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
state: true,
try {
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: charUUID,
state: true,
});
subscribedCharUUIDs.add(charUUID.toUpperCase().replace(/-/g, '').slice(-4));
console.log(`[ble] 已订阅适配器预定义: ${svcUUID} / ${charUUID}`);
} catch (err) {
console.warn(`[ble] 订阅失败 (预定义): ${charUUID}`, err);
}
}
// ── 第二轮:扫描全部服务,发现并订阅健康相关 Characteristic ──
for (const svc of services) {
const svcUUID = svc.uuid.toUpperCase();
const discoveredChars: BLEDiscoveredCharacteristic[] = [];
let charsRes: Taro.getBLEDeviceCharacteristics.SuccessCallbackResult;
try {
charsRes = await Taro.getBLEDeviceCharacteristics({
deviceId: device.deviceId,
serviceId: svc.uuid,
});
} catch (err) {
console.warn(`[ble] 读取特征列表失败: ${svcUUID}`, err);
continue;
}
const characteristics = charsRes.characteristics || [];
for (const char of characteristics) {
const charUUIDShort = char.uuid.toUpperCase().replace(/-/g, '').slice(-4);
const props = char.properties || {};
const discoveredChar: BLEDiscoveredCharacteristic = {
uuid: char.uuid,
properties: {
read: !!props.read,
write: !!props.write,
notify: !!props.notify,
indicate: !!props.indicate,
},
};
discoveredChars.push(discoveredChar);
// 如果是已知的健康 UUID 且尚未订阅,尝试订阅
if (
BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort] &&
!subscribedCharUUIDs.has(charUUIDShort) &&
(props.notify || props.indicate)
) {
try {
await Taro.notifyBLECharacteristicValueChange({
deviceId: device.deviceId,
serviceId: svc.uuid,
characteristicId: char.uuid,
state: true,
});
subscribedCharUUIDs.add(charUUIDShort);
console.log(`[ble] 自动发现并订阅: ${BLEConnection.HEALTH_CHAR_UUIDS[charUUIDShort]} (${svcUUID} / ${char.uuid})`);
} catch (err) {
console.warn(`[ble] 自动订阅失败: ${char.uuid}`, err);
}
}
}
discoveredServices.push({
uuid: svc.uuid,
isPrimary: !!svc.isPrimary,
characteristics: discoveredChars,
});
}
// 存储发现结果到连接信息
if (this.conn) {
this.conn = { ...this.conn, discoveredServices };
}
console.log(`[ble] 服务发现完成: ${discoveredServices.length} 个服务, 已订阅 ${subscribedCharUUIDs.size} 个特征`);
console.log(`[ble] 已订阅的健康特征:`, [...subscribedCharUUIDs].map(
(s) => `${s}(${BLEConnection.HEALTH_CHAR_UUIDS[s] ?? '未知'})`
).join(', '));
}
/** 手动读取特征值 */

View File

@@ -23,8 +23,44 @@ const SERVICES: Record<string, { uuid: string; chars: { notify: string; read: st
read: '00002A35-0000-1000-8000-00805F9B34FB',
},
},
pulse_oximeter: {
uuid: '00001822-0000-1000-8000-00805F9B34FB',
chars: {
// PLX Continuous Measurement — 实时血氧+脉率
notify: '00002A5F-0000-1000-8000-00805F9B34FB',
// PLX Spot-Check Measurement — 单次测量
read: '00002A5E-0000-1000-8000-00805F9B34FB',
},
},
};
// ── IEEE 11073 SFLOAT 解析Bluetooth SIG 医疗 Profile 通用格式) ──
/** 特殊 SFLOAT 值 */
const SFLOAT_NAN = 0x07FF;
const SFLOAT_NRES = 0x0800;
const SFLOAT_POS_INF = 0x07FE;
const SFLOAT_NEG_INF = 0x0802;
function parseSFLOAT(view: DataView, offset: number): number | null {
if (offset + 2 > view.byteLength) return null;
const raw = view.getUint16(offset, true);
if (raw === SFLOAT_NAN || raw === SFLOAT_NRES) return null;
if (raw === SFLOAT_POS_INF) return Infinity;
if (raw === SFLOAT_NEG_INF) return -Infinity;
const signM = (raw >> 15) & 0x01;
const exp = (raw >> 12) & 0x07;
const mantissa = raw & 0x0FFF;
// 指数用 3 位补码表示0-3 正4-7 负)
const exponent = exp >= 4 ? exp - 8 : exp;
const signedMantissa = signM ? -(mantissa ^ 0x0FFF) - 1 : mantissa;
return signedMantissa * Math.pow(10, exponent);
}
// ── 解析器 ──
function parseHeartRate(data: ArrayBuffer): NormalizedReading | null {
@@ -66,6 +102,39 @@ function parseTemperature(data: ArrayBuffer): NormalizedReading | null {
};
}
/**
* 解析 Pulse Oximeter Service 数据
* PLX Continuous Measurement (0x2A5F) 和 Spot-Check (0x2A5E) 共用
* 格式: Flags(1B) + SpO2(SFLOAT 2B) + PulseRate(SFLOAT 2B) + optional...
*/
function parsePulseOximeter(data: ArrayBuffer): NormalizedReading[] {
const view = new DataView(data);
if (view.byteLength < 5) return [];
const spO2 = parseSFLOAT(view, 1);
const pulseRate = parseSFLOAT(view, 3);
const now = new Date().toISOString();
const results: NormalizedReading[] = [];
if (spO2 !== null && spO2 >= 0 && spO2 <= 100) {
results.push({
device_type: 'blood_oxygen',
values: { blood_oxygen: Math.round(spO2), unit: '%' },
measured_at: now,
});
}
if (pulseRate !== null && pulseRate > 0 && pulseRate <= 300) {
results.push({
device_type: 'heart_rate',
values: { heart_rate: Math.round(pulseRate) },
measured_at: now,
});
}
return results;
}
// ── 工厂函数 ──
export interface GenericAdapterConfig {
@@ -100,20 +169,23 @@ export function createGenericBleAdapter(config: GenericAdapterConfig): DeviceAda
): NormalizedReading[] {
const upper = charUUID.toUpperCase();
// Heart Rate Measurement
const hrsChar = SERVICES.heart_rate.chars.notify.toUpperCase();
if (upper === hrsChar || upper.includes('2A37')) {
// Heart Rate Measurement (0x2A37)
if (upper.includes('2A37')) {
const result = parseHeartRate(data);
return result ? [result] : [];
}
// Temperature Measurement
const htChar = SERVICES.health_thermometer.chars.notify.toUpperCase();
if (upper === htChar || upper.includes('2A1C')) {
// Temperature Measurement (0x2A1C)
if (upper.includes('2A1C')) {
const result = parseTemperature(data);
return result ? [result] : [];
}
// Pulse Oximeter Continuous (0x2A5F) / Spot-Check (0x2A5E)
if (upper.includes('2A5F') || upper.includes('2A5E')) {
return parsePulseOximeter(data);
}
return [];
},
@@ -155,7 +227,7 @@ export const HuaweiBandAdapter = createGenericBleAdapter({
'华为手环',
'华为手表',
],
profiles: ['heart_rate', 'health_thermometer'],
profiles: ['heart_rate', 'health_thermometer', 'pulse_oximeter'],
});
/**

View File

@@ -72,6 +72,8 @@ export interface BLEConnection {
adapter: DeviceAdapter;
connectedAt?: number;
error?: string;
/** 连接后扫描到的全部服务(用于调试和展示) */
discoveredServices?: BLEDiscoveredService[];
}
/** 同步操作结果 */
@@ -96,7 +98,20 @@ export interface BLEManagerConfig {
export type GenericBLEProfile =
| 'heart_rate' // Heart Rate Service (0x180D)
| 'health_thermometer' // Health Thermometer Service (0x1809)
| 'blood_pressure'; // Blood Pressure Service (0x1810)
| 'blood_pressure' // Blood Pressure Service (0x1810)
| 'pulse_oximeter'; // Pulse Oximeter Service (0x1822)
/** BLE 服务发现结果(连接后扫描到的全部服务/特征) */
export interface BLEDiscoveredCharacteristic {
uuid: string;
properties: { read: boolean; write: boolean; notify: boolean; indicate: boolean };
}
export interface BLEDiscoveredService {
uuid: string;
isPrimary: boolean;
characteristics: BLEDiscoveredCharacteristic[];
}
/** 微信 BLE 扫描回调结果 */
export interface BLEScanResult {

View File

@@ -30,6 +30,7 @@ const OFFLINE_MAX_MS = 30_000;
let offlineDetectedAt = 0;
let offlineSuppressMs = OFFLINE_SUPPRESS_MS;
let networkToastShown = false;
let networkToastTimer: ReturnType<typeof setTimeout> | null = null;
let consecutiveNetErrors = 0;
function isOffline(): boolean {
@@ -44,7 +45,8 @@ function markOffline(): void {
if (!networkToastShown) {
networkToastShown = true;
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 });
setTimeout(() => { networkToastShown = false; }, offlineSuppressMs);
if (networkToastTimer) clearTimeout(networkToastTimer);
networkToastTimer = setTimeout(() => { networkToastShown = false; networkToastTimer = null; }, offlineSuppressMs);
}
}
@@ -52,6 +54,8 @@ function clearOffline(): void {
offlineDetectedAt = 0;
offlineSuppressMs = OFFLINE_SUPPRESS_MS;
consecutiveNetErrors = 0;
if (networkToastTimer) { clearTimeout(networkToastTimer); networkToastTimer = null; }
networkToastShown = false;
}
function safeGet(key: string): string {
@@ -157,9 +161,15 @@ async function doRefresh(): Promise<boolean> {
let reLaunchPromise: Promise<void> | null = null;
function safeReLaunch(url: string): void {
// 已在目标页,跳过(防止 DevTools reLaunch bug
const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || '';
if (currentPath.includes('pages/login')) return;
if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url }).then(() => {}, (err) => {
console.warn('[request] reLaunch failed:', err);
// reLaunch 失败时降级为 redirectTo
Taro.redirectTo({ url }).catch(() => {});
}).then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000);
});

View File

@@ -293,6 +293,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
resetAllStores();
set({ user: null, roles: [], currentPatient: null, patients: [] });
Taro.reLaunch({ url: '/pages/index/index' });
Taro.reLaunch({ url: '/pages/index/index' }).catch((err) => {
console.warn('[auth] reLaunch after logout failed:', err);
Taro.redirectTo({ url: '/pages/index/index' }).catch(() => {});
});
},
}));

15
apps/miniprogram/src/types/mp-html.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare namespace JSX {
interface IntrinsicElements {
'mp-html': {
content?: string;
'lazy-load'?: boolean;
selectable?: boolean;
'show-img-menu'?: boolean;
domain?: string;
'tag-style'?: string;
'link-style'?: string;
'container-style'?: string;
onReady?: () => void;
};
}
}

View File

@@ -3,8 +3,26 @@ import Taro from '@tarojs/taro';
const LOGIN_PAGE = '/pages/login/index';
const MAX_PAGE_STACK = 9;
// reLaunch 去重:避免 401 + 并发请求同时触发多个 reLaunch
let reLaunchPromise: Promise<void> | null = null;
export function navigateToLogin() {
Taro.reLaunch({ url: LOGIN_PAGE });
// 已在登录页,跳过
const pages = Taro.getCurrentPages();
const currentPath = pages[pages.length - 1]?.path || '';
if (currentPath.includes('pages/login')) return;
// 去重:上一个 reLaunch 还没完成就跳过
if (reLaunchPromise) return;
reLaunchPromise = Taro.reLaunch({ url: LOGIN_PAGE })
.catch((err) => {
console.warn('[navigate] reLaunch to login failed:', err);
// reLaunch 失败时降级为 redirectTo
Taro.redirectTo({ url: LOGIN_PAGE }).catch(() => {});
})
.then(() => {
setTimeout(() => { reLaunchPromise = null; }, 2000);
});
}
export function safeNavigateTo(url: string): void {

View File

@@ -11,11 +11,12 @@ const ALLOWED_TAGS = new Set([
]);
const ALLOWED_ATTRS: Record<string, Set<string>> = {
'*': new Set(['class']),
'*': new Set(['class', 'style', 'data-w-e-type']),
a: new Set(['href', 'title']),
img: new Set(['src', 'alt', 'width', 'height']),
td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan']),
span: new Set(['style']),
};
const URL_ATTRS = new Set(['href', 'src']);

View File

@@ -26,7 +26,7 @@ $env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026"
$env:ERP__REDIS__URL = "redis://localhost:6379"
$env:ERP__WECHAT__APPID = "wx20f4ef9cc2ec66c5"
$env:ERP__WECHAT__SECRET = "52679a563af519590e882c4b8d846f7b"
$env:ERP__WECHAT__DEV_MODE = "true"
$env:ERP__WECHAT__DEV_MODE = "false"
$env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
$env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5"
$env:ERP__RATE_LIMIT__FAIL_CLOSE = "false"

View File

@@ -4,7 +4,7 @@
## 关键数字
> 最后更新: 2026-05-24 | 数据截止: feat/media-library-banner 分支(小程序 Phase 2+3 实施完成
> 最后更新: 2026-05-24 | 数据截止: feat/media-library-banner 分支(小程序 DevTools 卡死排查 + 构建优化
| 指标 | 值 |
|------|-----|
@@ -18,7 +18,7 @@
| erp-ai 实体 | 20 个 Entity95 文件4 AI Providerchat_handler 支持 FC/Ollama fallback |
| 全系统 Entity | **115 个**58 health + 20 ai + 33 基础 + 4 core |
| Web 前端 | 316 个 TS/TSX 文件54 活跃路由83 API 模块108 页面) |
| 微信小程序 | Taro 4.2 + React 18180 个 TS/TSX 文件 / 61 页面(15 主包 + 46 分包) / 4 TabBar + 医生端独立分包34 组件(ui 21 + patterns 4 + 独立 9) / 45 service 文件 / 4 Zustand store / 12 hooks统一组件库 + CSS 变量主题102 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重 + 分包预加载 preloadRule**构建优化**`lazyCodeLoading: requiredComponents` 按需注入,主包 2.0MB→766KBtaro.js 526→131KB / vendors.js 230→28KB**离线抑制**指数退避3s→6s→12s→30s cap防请求洪泛**五维度分析评分 6.7/10**架构7.25/安全6.0/UX7.4/工程6.2 |
| 微信小程序 | Taro 4.2 + React 18180 个 TS/TSX 文件 / 61 页面(15 主包 + 46 分包) / 4 TabBar + 医生端独立分包34 组件(ui 21 + patterns 4 + 独立 9) / 45 service 文件 / 4 Zustand store / 12 hooks统一组件库 + CSS 变量主题102 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重 + 分包预加载 preloadRule**构建优化**`lazyCodeLoading: requiredComponents` 仅生产构建启用dev 下已知 DevTools 卡死 bug`addChunkPages` 仅 TabBar 页注入 common chunk主包 dev 892KB / prod 766KBtaro.js 526→131KB / vendors.js 230→28KB**DevTools 兼容**:游客首页 Swiper dev 模式禁用 circular + 间隔 15s防 DevTools Chromium 渲染进程逐渐卡死**离线抑制**指数退避3s→6s→12s→30s cap防请求洪泛**五维度分析评分 6.7/10**架构7.25/安全6.0/UX7.4/工程6.2 |
| 前端测试 | Web 62 单元测试文件(~693 断言) + 17 E2E spec(13 Web + 4 MP~64 断言);小程序 12 单元测试文件(127 断言) + 4 E2E spec(~16 断言),覆盖率 ~6% |
| 后端测试 | **1030 个函数**839 同步 + 191 异步96 个文件含测试 |
| 事件系统 | 31 事件类型health/ 51 全系统 / 82 发布点 / 15 消费者模块 / Outbox + LISTEN/NOTIFY |
@@ -66,7 +66,7 @@
| 告警管理按钮不显示 | [[frontend]] 权限码拼写 | AlertList.tsx | `health.alert.manage``health.alerts.manage`(缺 s |
| 小程序晚间血压丢失 | [[miniprogram]] 体征录入 | indicator_type 映射 | **已修复:** 新增 `blood_pressure_evening` 类型,录入页+日常监测页+后端+测试全覆盖 |
| 咨询页长轮询 CPU 飙升 | [[miniprogram]] §5 审查 | longPoll delay=0 递归 | **已修复:** 成功路径加 3s 间隔 + 连续失败上限 50 次 |
| 小程序 DevTools 卡死(所有 API ERR_SSL_PROTOCOL_ERROR | [[miniprogram]] request.ts | `process.env.NODE_ENV === 'production'` 时 http→https 自动转换 | **已修复** 移除 `getHeaders` 中自动 http→https 升级URL 以 `.env` 为唯一来源 |
| 小程序 DevTools 卡死(所有 API ERR_SSL_PROTOCOL_ERROR | [[infrastructure]] 构建模式 | 用了 `build:weapp`(生产构建)→ `.env.production` `https://api.hms.example.com`(无 TLS→ 全部 SSL 错误 → 离线抑制 → 卡死 | **解决** DevTools 调试用 `dev:weapp`(使用 `.env``http://` 开发地址),不用 `build:weapp`2026-05-25 确认根因) |
| 小程序 Tab 切换卡死(并发请求阻塞 30s | [[miniprogram]] request.ts | `getHeaders()``await tryRefreshToken()` 预检查 | **已修复:** 移除 getHeaders 中的同步 Token 刷新预检查,仅依赖 401 重试路径 |
| 小程序患者端登录后卡死Tab 切换频繁卡死) | [[miniprogram]] §5 审查 | 并发请求超微信 10 限制排队 + 长轮询重叠 + 防重入缺失 | **已修复:** 全局并发限制 MAX_CONCURRENT=8 + generation counter 长轮询 + loadingRef 防重入 |
| 小程序咨询页闭会话崩溃pollingRef is not defined | [[miniprogram]] 审计第二轮 | generation counter 重构后 loadData 残留 pollingRef 引用 | **已修复:** 移除 loadData 中 `pollingRef.current = false` 残余行 |
@@ -140,7 +140,13 @@
| Article Handler 缺 .validate() | [[erp-health]] article_handler | ~15 个 handler 缺少 .validate() 调用 | **已修复:** article/category/tag handler 全部补齐 .validate() + DTO Validate derive2026-05-21 |
| 前端 16 处 any 类型 | [[frontend]] 多文件 | client/points/usePaginatedData/MediaLibrary/ArticleEditor | **已修复:** AxiosRequestConfig 增强 + OptionsConfig 类型 + TreeNode 接口16→12026-05-21 |
| 小程序患者端 403health-records/lab-reports/alerts/analytics/followups | [[miniprogram]] 权限配置 | patient 角色缺少 .manage 权限 | **已修复:** `m20260522_000162` 补齐 15 个 manage 权限 + 注册 `system.analytics.submit` 幽灵权限2026-05-22 |
| DevTools 打开即卡死 / 游客首页停留后 CPU 30%+ | [[miniprogram]] 构建配置 + request.ts | 主包 2MB 全量组件注入 + 离线时请求洪泛 | **已修复:** `lazyCodeLoading: requiredComponents`(主包 2.0→0.77MB+ 离线抑制指数退避 + TS readonly 兼容2026-05-24 |
| DevTools 打开即卡死 / 真机调试卡死 | [[miniprogram]] app.config.ts | `lazyCodeLoading: requiredComponents` 在 dev 模式下触发微信 DevTools 已知 bug | **已修复:**`NODE_ENV=production` 时启用 lazyCodeLoadingdev 构建不注入2026-05-24 |
| 小程序主包 2MB 超限 | [[miniprogram]] config/index.ts | `addChunkPages` 将 common chunk 注入所有主包页面 | **已修复:** 仅 TabBar 页面 + 根级页面注入 common chunk分包页面由自身 vendors.js 承载dev 892KB / prod 766KB2026-05-24 |
| 离线请求洪泛(每 3s 重试) | [[miniprogram]] request.ts | 固定 OFFLINE_SUPPRESS_MS=3000 无退避,断网时请求排队占满并发槽 | **已修复:** 指数退避 3s→6s→12s→30s cap + consecutiveNetErrors 累计计数2026-05-24 |
| DevTools 逐渐卡死CPU 15-30%,轮播图仍在动画但 UI 不可点击) | [[miniprogram]] 游客首页 Swiper | Swiper `circular` 克隆 DOM 节点 + 5s 间隔动画在 DevTools Chromium 渲染进程中持续积累内存 | **已修复:** dev 模式禁用 circular + interval 改为 15s生产模式不变2026-05-24 |
| TS 编译错误 `readonly Tab[]` 不可赋值给 `Tab[]` | [[miniprogram]] SegmentTabs | 页面组件用 `as const` 创建的 readonly 数组无法传入 mutable `Tab[]` 类型 | **已修复:** SegmentTabs 的 `Tab` 属性改为 `readonly` + `tabs` prop 改为 `readonly Tab[]`2026-05-24 |
| 重建失败 `dist/` 被锁定 | [[miniprogram]] 构建流程 | 微信 DevTools 进程持有 dist 目录文件句柄taro build 无法写入 | **解决:** `taskkill /F /IM wechatdevtools.exe` 后重新构建2026-05-24 |
| DevTools 打开即卡死所有项目Taro/原生均复现) | [[miniprogram]] appid 配置 | appid `wx20f4ef9cc2ec66c5` 的微信后台配置触发 `WAServiceMainContext.js` 内部 timeout导致 DevTools 渲染进程逐渐无响应;**根因定位:** 换用其他 appid如测试 appid `wx97debf52c9547da4`)后 Taro/原生均不卡死,确认是 appid 后台服务配置问题而非框架/代码问题 | **待解决:** 需到微信公众平台mp.weixin.qq.com检查该 appid 是否开通了云开发/云函数/第三方插件等导致 DevTools 初始化时连接超时的服务;临时方案:开发调试时使用测试 appid2026-05-24 |
## 模块导航

View File

@@ -1,6 +1,6 @@
---
title: 开发环境
updated: 2026-05-16
updated: 2026-05-25
status: stable
tags: [infrastructure, dev-environment, windows, postgresql]
---
@@ -128,12 +128,18 @@ cd apps/web && pnpm build # 构建生产版本
### 微信小程序
```bash
cd apps/miniprogram && pnpm run dev:weapp # 小程序开发模式(微信开发者工具打开 dist/
cd apps/miniprogram && pnpm run build:weapp # 小程序生产构建
cd apps/miniprogram && pnpm run dev:weapp # ✅ 开发调试(必须用这个!使用 .env 中的开发地址
cd apps/miniprogram && pnpm run build:weapp # 生产构建(使用 .env.production 中的生产域名DevTools 中不可用)
cd apps/miniprogram && pnpm run dev:h5 # H5 浏览器预览(端口 10086推荐调试用
cd apps/miniprogram && pnpm run build:h5 # H5 生产构建
```
> **构建模式区分(重要):**
> - `dev:weapp` → `NODE_ENV=development` → 加载 `.env` → `TARO_APP_API_URL=http://192.168.31.123:3000/api/v1`
> - `build:weapp` → `NODE_ENV=production` → 加载 `.env.production` → `TARO_APP_API_URL=https://api.hms.example.com/api/v1`
>
> **DevTools 中调试必须用 `dev:weapp`**,否则请求打到生产域名(无 TLS→ `ERR_SSL_PROTOCOL_ERROR` → 离线抑制触发 → DevTools 卡死。这是一个已踩坑的陷阱2026-05-25
> **调试建议:** 日常 UI 开发和页面调试优先用 `dev:h5`(浏览器热更新 + DevTools完成后在微信开发者工具中做最终验证。H5 模式下微信特有 API`wx.login`、`Taro.request` 微信登录态等)不可用,但页面布局和交互可完整预览。
>
> H5 模式通过 `.env.h5` 将 API URL 设为 `/api/v1`相对路径dev server 自动代理到 `localhost:3000`,无需处理 CORS。
@@ -190,6 +196,7 @@ cd apps/web && pnpm install && pnpm dev
| 日期 | 变更 |
|------|------|
| 2026-05-25 | 明确 dev:weapp vs build:weapp 构建模式区分,记录 .env.production 导致 DevTools 卡死的陷阱 |
| 2026-04-26 | 从 CLAUDE.md 迁移常用命令§9 |
| 2026-04-25 | 外部化微信凭据和健康加密密钥为环境变量;添加 4 个新的必设环境变量 |
| 2026-04-24 | 添加微信小程序配置信息和集成契约 |