Compare commits
5 Commits
ef1b8eb348
...
8ad4329632
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ad4329632 | ||
|
|
1a376a255d | ||
|
|
485b9bb926 | ||
|
|
185f411495 | ||
|
|
a24c18155f |
10
CLAUDE.md
10
CLAUDE.md
@@ -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).
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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"
|
||||
|
||||
8
apps/miniprogram/pnpm-lock.yaml
generated
8
apps/miniprogram/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"minifyWXML": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"ignoreUploadUnusedFiles": true
|
||||
"ignoreUploadUnusedFiles": true,
|
||||
"skylineRenderEnable": false
|
||||
},
|
||||
"condition": {}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export default defineAppConfig({
|
||||
// 仅生产构建启用,dev 模式下 lazyCodeLoading 导致 DevTools / 真机调试卡死
|
||||
...(process.env.NODE_ENV === 'production' ? { lazyCodeLoading: 'requiredComponents' as const } : {}),
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
|
||||
32
apps/miniprogram/src/components/RichArticle/index.tsx
Normal file
32
apps/miniprogram/src/components/RichArticle/index.tsx
Normal 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);
|
||||
@@ -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 (
|
||||
|
||||
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal file
8
apps/miniprogram/src/native-components/mp-html/index.js
Normal 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(/&/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}}});
|
||||
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node/node"}}
|
||||
@@ -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>
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
{"component":true,"usingComponents":{"node":"./node"}}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
1
apps/miniprogram/src/native-components/mp-html/parser.js
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '文章详情',
|
||||
usingComponents: {
|
||||
'mp-html': '../../../native-components/mp-html/index',
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -370,7 +370,8 @@ export default function Index() {
|
||||
url: target,
|
||||
fail: () => {
|
||||
redirectingRef.current = false;
|
||||
console.warn('跳转医生端失败,停留患者首页');
|
||||
console.warn('跳转医生端失败,降级为 redirectTo');
|
||||
Taro.redirectTo({ url: target }).catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">✓</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">✓</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">✓</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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(', '));
|
||||
}
|
||||
|
||||
/** 手动读取特征值 */
|
||||
|
||||
@@ -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'],
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
15
apps/miniprogram/src/types/mp-html.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
2
dev.ps1
2
dev.ps1
@@ -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"
|
||||
|
||||
@@ -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 个 Entity(95 文件,4 AI Provider,chat_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 18,180 个 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→766KB(taro.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 18,180 个 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 766KB(taro.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 derive(2026-05-21) |
|
||||
| 前端 16 处 any 类型 | [[frontend]] 多文件 | client/points/usePaginatedData/MediaLibrary/ArticleEditor | **已修复:** AxiosRequestConfig 增强 + OptionsConfig 类型 + TreeNode 接口,16→1(2026-05-21) |
|
||||
| 小程序患者端 403(health-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` 时启用 lazyCodeLoading,dev 构建不注入(2026-05-24) |
|
||||
| 小程序主包 2MB 超限 | [[miniprogram]] config/index.ts | `addChunkPages` 将 common chunk 注入所有主包页面 | **已修复:** 仅 TabBar 页面 + 根级页面注入 common chunk,分包页面由自身 vendors.js 承载(dev 892KB / prod 766KB)(2026-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 初始化时连接超时的服务;临时方案:开发调试时使用测试 appid(2026-05-24) |
|
||||
|
||||
## 模块导航
|
||||
|
||||
|
||||
@@ -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 | 添加微信小程序配置信息和集成契约 |
|
||||
|
||||
Reference in New Issue
Block a user