@tbox.cn/app-template-agent 0.7.5 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitignore +5 -0
- package/.tbox/app.json +9 -1
- package/AGENTS.md +12 -4
- package/ENV.md +102 -17
- package/README.md +36 -34
- package/apps/client/VISUAL_RESTORE.md +11 -11
- package/apps/client/index.html +3 -1
- package/apps/client/package.json +9 -8
- package/apps/client/src/business/App.tsx +4 -4
- package/apps/client/src/business/README.md +5 -11
- package/apps/client/src/cards/registry.ts +23 -8
- package/apps/client/src/main.tsx +36 -2
- package/apps/client/src/modules.ts +7 -4
- package/apps/client/src/pages/index/index.tsx +18 -162
- package/apps/client/src/platform/AgentChatApp.tsx +3 -3
- package/apps/client/src/platform/agent-runtime.ts +5 -0
- package/apps/client/src/store/chat.ts +47 -185
- package/apps/client/src/test/setup.ts +7 -0
- package/apps/client/vite.config.ts +16 -1
- package/apps/server/package.json +0 -1
- package/apps/server/src/app-tools.ts +2 -1
- package/apps/server/src/app.ts +178 -110
- package/apps/server/src/config/integration.ts +2 -6
- package/apps/server/src/dev.ts +0 -1
- package/apps/server/src/intent-routes.ts +3 -3
- package/apps/server/src/modules.ts +4 -4
- package/apps/server/src/pre-tools.ts +4 -3
- package/apps/server/tests/agent-prompt.test.ts +81 -0
- package/apps/server/tests/cards-registry.test.ts +1 -3
- package/apps/server/tests/deployment-loader.test.ts +35 -56
- package/apps/server/tests/tool-runtime.test.ts +25 -61
- package/package.json +5 -4
- package/packages/agent-ui-kit/LICENSE +21 -0
- package/packages/agent-ui-kit/README.md +17 -0
- package/packages/agent-ui-kit/package.json +58 -0
- package/packages/agent-ui-kit/src/ChatPage.less +62 -0
- package/packages/agent-ui-kit/src/ChatPage.tsx +305 -0
- package/packages/agent-ui-kit/src/chat-config.test.ts +82 -0
- package/packages/agent-ui-kit/src/chat-config.ts +62 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/DebugButton.tsx +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.less +19 -16
- package/{apps/client → packages/agent-ui-kit}/src/components/ActionBar/index.tsx +10 -13
- package/packages/agent-ui-kit/src/components/CardRenderer.tsx +36 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.less +148 -0
- package/packages/agent-ui-kit/src/components/ChatHeader/index.test.tsx +74 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatHeader/index.tsx +27 -15
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.less +47 -39
- package/packages/agent-ui-kit/src/components/ChatInput/index.test.tsx +156 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/index.tsx +13 -4
- package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/useVoicePress.ts +2 -2
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.less +49 -0
- package/packages/agent-ui-kit/src/components/HistoryEntry/index.tsx +42 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.less +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/components/MediaList/index.tsx +6 -6
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.less +25 -20
- package/packages/agent-ui-kit/src/components/MessageBubble/index.test.tsx +112 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/MessageBubble/index.tsx +22 -15
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.less +2 -2
- package/{apps/client → packages/agent-ui-kit}/src/components/PullDownIndicator/index.tsx +2 -2
- package/packages/agent-ui-kit/src/components/QuickCommands/index.less +63 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.test.tsx +99 -0
- package/packages/agent-ui-kit/src/components/QuickCommands/index.tsx +52 -0
- package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.less +1 -1
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.test.ts +346 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/detail.ts +204 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.less +312 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.test.tsx +261 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/index.tsx +212 -0
- package/packages/agent-ui-kit/src/components/ToolCallBlock/titles.ts +102 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.test.ts +66 -0
- package/packages/agent-ui-kit/src/hooks/use-drag-gesture.ts +129 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-history.ts +241 -0
- package/packages/agent-ui-kit/src/hooks/use-pull-down-wheel-native.test.ts +64 -0
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useMediaUpload.ts +3 -3
- package/{apps/client → packages/agent-ui-kit}/src/hooks/useTTSAutoPlay.ts +7 -5
- package/packages/agent-ui-kit/src/index.ts +69 -0
- package/packages/agent-ui-kit/src/store-context.tsx +92 -0
- package/packages/agent-ui-kit/src/test/setup.ts +7 -0
- package/packages/agent-ui-kit/src/theme/contract.json +72 -0
- package/packages/agent-ui-kit/src/theme/global.less +99 -0
- package/packages/agent-ui-kit/src/theme/motion.less +19 -0
- package/packages/agent-ui-kit/src/theme/theme-contract.test.ts +69 -0
- package/packages/agent-ui-kit/src/theme/theme.ts +17 -0
- package/packages/agent-ui-kit/src/theme/tokens.less +79 -0
- package/packages/agent-ui-kit/src/utils/haptics.test.ts +51 -0
- package/packages/agent-ui-kit/src/utils/haptics.ts +44 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.test.ts +188 -0
- package/packages/agent-ui-kit/src/utils/pull-down-machine.ts +157 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.test.ts +240 -0
- package/packages/agent-ui-kit/src/utils/tool-call-display.ts +146 -0
- package/packages/agent-ui-kit/src/vite-env.d.ts +1 -0
- package/packages/agent-ui-kit/tsconfig.json +8 -0
- package/packages/agent-ui-kit/vitest.config.ts +7 -0
- package/pnpm-lock.template.yaml +177 -83
- package/pnpm-workspace.template.yaml +12 -4
- package/profiles/mall/config/agent.jsonc +33 -0
- package/profiles/mall/config/chat.json +21 -0
- package/profiles/mall/config/knowledge.json +5 -0
- package/profiles/mall/config/llm_provider.json +5 -0
- package/profiles/mall/config/plugins.json +3 -0
- package/profiles/mall.json +16 -0
- package/scripts/check-workspace.mjs +3 -2
- package/apps/client/assets/copy@2x.png +0 -0
- package/apps/client/assets/debug.png +0 -0
- package/apps/client/assets/dislike@2x.png +0 -0
- package/apps/client/assets/like@2x.png +0 -0
- package/apps/client/assets/speaker@2x.png +0 -0
- package/apps/client/src/cards/CardRenderer.tsx +0 -20
- package/apps/client/src/components/ChatHeader/index.less +0 -104
- package/apps/client/src/components/ErrorBoundary.tsx +0 -2
- package/apps/client/src/components/QuickCommands/index.less +0 -48
- package/apps/client/src/components/QuickCommands/index.tsx +0 -29
- package/apps/client/src/components/ToolCallBlock/index.less +0 -131
- package/apps/client/src/components/ToolCallBlock/index.tsx +0 -125
- package/apps/client/src/examples/tourism/TourismLanding.tsx +0 -96
- package/apps/client/src/examples/tourism/index.less +0 -192
- package/apps/client/src/pages/index/index.less +0 -43
- package/apps/client/src/store/chat-config.ts +0 -40
- package/apps/client/src/styles/global.less +0 -60
- package/apps/client/src/styles/tokens.less +0 -60
- package/apps/server/src/config/deployment.ts +0 -47
- package/packages/module-weather/package.json +0 -36
- package/packages/module-weather/skills/weather-guide/SKILL.md +0 -22
- package/packages/module-weather/skills/weather-guide/references/dressing-tips.md +0 -5
- package/packages/module-weather/src/client/cards/weather/index.less +0 -22
- package/packages/module-weather/src/client/cards/weather/index.tsx +0 -14
- package/packages/module-weather/src/client/index.ts +0 -19
- package/packages/module-weather/src/index.ts +0 -2
- package/packages/module-weather/src/server/cards/weather/meta.ts +0 -27
- package/packages/module-weather/src/server/index.ts +0 -45
- package/packages/module-weather/src/server/pre-tools.ts +0 -20
- package/packages/module-weather/src/server/tool.ts +0 -29
- package/packages/module-weather/tbox.module.json +0 -27
- package/packages/module-weather/tsconfig.json +0 -14
- /package/{apps/client → packages/agent-ui-kit}/assets/keyboard-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/more-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/new-chat.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/quick-command-star.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/send-gradient.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/stop@2x.png +0 -0
- /package/{apps/client → packages/agent-ui-kit}/assets/voice-circle.svg +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoicePressField.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/VoiceRecordingOverlay.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ChatInput/voice/machine.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.less +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ReasoningBlock/index.tsx +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/components/ScrollToBottomButton/index.tsx +0 -0
- /package/{apps/client/src/types/chat.ts → packages/agent-ui-kit/src/types.ts} +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/copyText.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/imagePreview.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/session-error.ts +0 -0
- /package/{apps/client → packages/agent-ui-kit}/src/utils/toast.ts +0 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "主题可覆写变量封闭清单(主题轨 W-T2;doctor theme-package-structure 消费——超集即 error;theme-contract.test.ts 锁 tokens.less+motion.less 的平台键 ⊆ platform 段)。domains 段 = 域色两段式通道(identity/surface/border 三角色,v1 封闭;promotion 按域色矩阵无描边列)。assets 段 = 素材变量链语义位(W-T3 铺开,铺开后逐一核对)。",
|
|
3
|
+
"platform": [
|
|
4
|
+
"--tbox-accent",
|
|
5
|
+
"--tbox-accent-light",
|
|
6
|
+
"--tbox-bg-card",
|
|
7
|
+
"--tbox-bg-page",
|
|
8
|
+
"--tbox-bg-subtle",
|
|
9
|
+
"--tbox-bg-warm",
|
|
10
|
+
"--tbox-border",
|
|
11
|
+
"--tbox-border-strong",
|
|
12
|
+
"--tbox-content-max-width",
|
|
13
|
+
"--tbox-error",
|
|
14
|
+
"--tbox-fs-body",
|
|
15
|
+
"--tbox-fs-caption",
|
|
16
|
+
"--tbox-fs-heading",
|
|
17
|
+
"--tbox-icon-color",
|
|
18
|
+
"--tbox-ink",
|
|
19
|
+
"--tbox-input-bg",
|
|
20
|
+
"--tbox-input-padding",
|
|
21
|
+
"--tbox-input-radius",
|
|
22
|
+
"--tbox-media-item-radius",
|
|
23
|
+
"--tbox-media-item-size",
|
|
24
|
+
"--tbox-motion-duration-base",
|
|
25
|
+
"--tbox-motion-duration-fast",
|
|
26
|
+
"--tbox-motion-duration-slow",
|
|
27
|
+
"--tbox-motion-easing-emphasized",
|
|
28
|
+
"--tbox-motion-easing-standard",
|
|
29
|
+
"--tbox-page-gutter",
|
|
30
|
+
"--tbox-primary",
|
|
31
|
+
"--tbox-primary-dark",
|
|
32
|
+
"--tbox-primary-light",
|
|
33
|
+
"--tbox-radius-lg",
|
|
34
|
+
"--tbox-radius-md",
|
|
35
|
+
"--tbox-radius-sm",
|
|
36
|
+
"--tbox-send-btn-size",
|
|
37
|
+
"--tbox-shadow-lg",
|
|
38
|
+
"--tbox-shadow-md",
|
|
39
|
+
"--tbox-shadow-sm",
|
|
40
|
+
"--tbox-success",
|
|
41
|
+
"--tbox-text-muted",
|
|
42
|
+
"--tbox-text-primary",
|
|
43
|
+
"--tbox-text-secondary",
|
|
44
|
+
"--tbox-transition-base",
|
|
45
|
+
"--tbox-transition-fast",
|
|
46
|
+
"--tbox-warning"
|
|
47
|
+
],
|
|
48
|
+
"domains": [
|
|
49
|
+
"--tbox-domain-member-accent",
|
|
50
|
+
"--tbox-domain-member-border",
|
|
51
|
+
"--tbox-domain-member-surface",
|
|
52
|
+
"--tbox-domain-parking-accent",
|
|
53
|
+
"--tbox-domain-parking-border",
|
|
54
|
+
"--tbox-domain-parking-surface",
|
|
55
|
+
"--tbox-domain-promotion-accent",
|
|
56
|
+
"--tbox-domain-promotion-surface",
|
|
57
|
+
"--tbox-domain-sg-accent",
|
|
58
|
+
"--tbox-domain-sg-border",
|
|
59
|
+
"--tbox-domain-sg-surface"
|
|
60
|
+
],
|
|
61
|
+
"assets": [
|
|
62
|
+
"--tbox-asset-bumpin-bg",
|
|
63
|
+
"--tbox-asset-home-poster-compact",
|
|
64
|
+
"--tbox-asset-home-poster-stage",
|
|
65
|
+
"--tbox-asset-member-login-hero",
|
|
66
|
+
"--tbox-asset-member-mascot",
|
|
67
|
+
"--tbox-asset-parking-hero",
|
|
68
|
+
"--tbox-asset-promotion-activity-header",
|
|
69
|
+
"--tbox-asset-promotion-coupon-header",
|
|
70
|
+
"--tbox-asset-sg-header"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
@import './tokens.less';
|
|
2
|
+
@import './motion.less';
|
|
3
|
+
|
|
4
|
+
* {
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
html,
|
|
9
|
+
body,
|
|
10
|
+
#root {
|
|
11
|
+
margin: 0;
|
|
12
|
+
padding: 0;
|
|
13
|
+
height: 100%;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
/* mall-vis 字栈:demo 拉丁栈 + 显式 CJK(demo 依赖容器隐式 CJK,模板面向开放浏览器) */
|
|
18
|
+
font-family:
|
|
19
|
+
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC',
|
|
20
|
+
'Microsoft YaHei', sans-serif;
|
|
21
|
+
font-size: var(--tbox-fs-body);
|
|
22
|
+
color: var(--tbox-text-primary);
|
|
23
|
+
background: var(--tbox-bg-page);
|
|
24
|
+
-webkit-font-smoothing: antialiased;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* 顶层容器全宽(渐变底铺满),内容区由各处 max-width:800px 居中(对齐原模板 #app)。 */
|
|
28
|
+
#root {
|
|
29
|
+
width: 100%;
|
|
30
|
+
position: relative;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* 去掉移动端点击高亮 */
|
|
34
|
+
* {
|
|
35
|
+
-webkit-tap-highlight-color: transparent;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* 卡片统一外层 = 卡片「卡框」。所有卡片共有的框在这里,单卡组件别重复写:
|
|
39
|
+
- 间距:卡片与上下元素(工具调用 / 气泡)的间距,与 .tool-calls 一致;
|
|
40
|
+
- 外观:默认背景(白) / 2px 边框 / 圆角 / 阴影;`overflow:hidden` 把内容裁进圆角;
|
|
41
|
+
- 入场动效:mount 即播(0.36s 上浮淡入,对齐 mall-vis 消息卡节奏)。
|
|
42
|
+
值为源码 px(2x 基线、折半渲染):圆角 var(--tbox-radius-lg)=48≈24(与气泡 48 同口径)、边框 2≈1、阴影 var(--tbox-shadow-sm)≈0 4px 13px。
|
|
43
|
+
⚠️ **内边距不在卡框里**——由卡片自己写。卡框给 padding 会让「自带背景色的卡」在外面露一圈白边(卡里套卡)。
|
|
44
|
+
宽度也不在卡框里:默认占满整列(≈移动端 350~400px 设计宽),紧凑卡自己写 max-width。
|
|
45
|
+
单卡组件(.tbox-<type>)只画内容;**要整卡换底色 / 出血图,直接在自己 root 上 `background:<色>` 或铺满图**——
|
|
46
|
+
会铺满卡框、裁进圆角、不出白边(别再自己设圆角,卡框已裁)。不写背景则露卡框默认白底,不透明。 */
|
|
47
|
+
.tbox-card {
|
|
48
|
+
margin-bottom: 12px;
|
|
49
|
+
background: var(--tbox-bg-card);
|
|
50
|
+
border: 2px solid var(--tbox-border);
|
|
51
|
+
border-radius: var(--tbox-radius-lg);
|
|
52
|
+
box-shadow: var(--tbox-shadow-sm);
|
|
53
|
+
overflow: hidden;
|
|
54
|
+
animation: tbox-card-enter var(--tbox-motion-duration-slow) ease;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@keyframes tbox-card-enter {
|
|
58
|
+
from {
|
|
59
|
+
opacity: 0;
|
|
60
|
+
transform: translateY(16px);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
to {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
transform: translateY(0);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@media (prefers-reduced-motion: reduce) {
|
|
70
|
+
.tbox-card {
|
|
71
|
+
animation: none;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* 多卡纵向容器(SDK CardCarousel 多卡分支):容器内卡片关闭逐卡入场动画——
|
|
76
|
+
同屏多卡逐张挂载时 0.36s 上浮淡入会交叠抖动;单卡直出与其他场景的入场动画保留
|
|
77
|
+
(命中卡片本体 .tbox-card:卡框动画在自身规则上,item 中间层无动画规则) */
|
|
78
|
+
.tbox-card-stack .tbox-card {
|
|
79
|
+
animation: none;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* 兜底态(未知 cardType / 渲染出错,由 CardRenderer 给外层加此修饰类):
|
|
83
|
+
复用卡框,弱化成无阴影的虚线提示框(内边距自带,因为卡框不再给 padding) */
|
|
84
|
+
.tbox-card--fallback {
|
|
85
|
+
max-width: 80%;
|
|
86
|
+
padding: 32px 24px;
|
|
87
|
+
background: var(--tbox-bg-subtle);
|
|
88
|
+
border: 1px dashed var(--tbox-border);
|
|
89
|
+
box-shadow: none;
|
|
90
|
+
color: var(--tbox-text-muted);
|
|
91
|
+
font-size: var(--tbox-fs-caption);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ===== antd-mobile 全局弹层层级(2026-09-04):业务半屏浮层(门店详情/门店导览)z-index 1100,
|
|
95
|
+
* Toast/Dialog/Popup 等瞬态反馈弹层默认 1000 会被盖住(门店券行领取的弹出提醒不可见实证)——
|
|
96
|
+
* 统一提至 1200 置于一切业务浮层之上(antd-mobile 官方 :root:root 双写覆盖约定)===== */
|
|
97
|
+
:root:root {
|
|
98
|
+
--adm-z-index-mask: 1200;
|
|
99
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 动效 token(主题轨 W-T2)——过渡节奏与缓动的平台级变量。
|
|
3
|
+
*
|
|
4
|
+
* 主题可覆写面:主题包 `html[data-theme='<name>']` 段覆写本文件变量即可整体换节奏
|
|
5
|
+
* (覆写值须在 contract.json platform 段封闭清单内——doctor 25 校验)。
|
|
6
|
+
* duration 三档对齐现状众数(fast 0.18s 既有 token 值 / base 0.28s 既有 / slow 0.36s 卡入场);
|
|
7
|
+
* easing standard = 既有 transition token 曲线(值零变化)。
|
|
8
|
+
* 循环动画(spin/pulse/blink/wave)与交错 delay 的节奏属组件语义,不入本族(v1 封闭)。
|
|
9
|
+
*/
|
|
10
|
+
:root {
|
|
11
|
+
/* duration:交互反馈(按压/hover/快挂载)、面板与弹层过渡、入场主过渡 */
|
|
12
|
+
--tbox-motion-duration-fast: 0.18s;
|
|
13
|
+
--tbox-motion-duration-base: 0.28s;
|
|
14
|
+
--tbox-motion-duration-slow: 0.36s;
|
|
15
|
+
|
|
16
|
+
/* easing:标准曲线(= 既有 --tbox-transition-* 曲线);强调曲线留给主题与后续组件 */
|
|
17
|
+
--tbox-motion-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
|
18
|
+
--tbox-motion-easing-emphasized: cubic-bezier(0.2, 0, 0, 1);
|
|
19
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题契约锁(主题轨 W-T2):contract.json 单一真源 ↔ kit css 机械一致。
|
|
3
|
+
*
|
|
4
|
+
* 断言:
|
|
5
|
+
* ① tokens.less + motion.less 的 :root 变量集 ⊆ contract.platform(css 演进不漂移清单);
|
|
6
|
+
* ② contract 三段非空且全局唯一(无重复键);
|
|
7
|
+
* ③ domains/assets 段命名空间前缀正确(--tbox-domain-* / --tbox-asset-*)。
|
|
8
|
+
*
|
|
9
|
+
* doctor theme-package-structure 只读 contract.json(node fs),本测试是"css ⊆ json"的仓内锁。
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { describe, expect, it } from 'vitest';
|
|
15
|
+
|
|
16
|
+
const themeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const contract = JSON.parse(readFileSync(path.join(themeDir, 'contract.json'), 'utf-8')) as {
|
|
18
|
+
platform: string[];
|
|
19
|
+
domains: string[];
|
|
20
|
+
assets: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function extractRootVars(file: string): string[] {
|
|
24
|
+
const css = readFileSync(path.join(themeDir, file), 'utf-8');
|
|
25
|
+
const names: string[] = [];
|
|
26
|
+
// 仅 :root 块内声明(行首 --tbox-*,值可含换行;本包 tokens/motion 均为一行一变量)
|
|
27
|
+
let inRoot = false;
|
|
28
|
+
for (const line of css.split('\n')) {
|
|
29
|
+
if (/^:root\s*\{/.test(line)) inRoot = true;
|
|
30
|
+
else if (inRoot && line.trim() === '}') inRoot = false;
|
|
31
|
+
else if (inRoot) {
|
|
32
|
+
const m = line.match(/^\s*(--tbox-[a-z0-9-]+)\s*:/);
|
|
33
|
+
if (m) names.push(m[1]);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return names;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('主题契约(contract.json)', () => {
|
|
40
|
+
it('tokens.less + motion.less 的 :root 变量 ⊆ contract.platform', () => {
|
|
41
|
+
const cssVars = [...extractRootVars('tokens.less'), ...extractRootVars('motion.less')];
|
|
42
|
+
expect(cssVars.length).toBeGreaterThan(40);
|
|
43
|
+
const allowed = new Set(contract.platform);
|
|
44
|
+
const drift = cssVars.filter((v) => !allowed.has(v));
|
|
45
|
+
expect(drift, `css 变量未登记进 contract.platform:${drift.join(', ')}`).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('三段非空、全局键唯一、前缀正确', () => {
|
|
49
|
+
expect(contract.platform.length).toBeGreaterThan(0);
|
|
50
|
+
expect(contract.domains.length).toBeGreaterThan(0);
|
|
51
|
+
expect(contract.assets.length).toBeGreaterThan(0);
|
|
52
|
+
const all = [...contract.platform, ...contract.domains, ...contract.assets];
|
|
53
|
+
expect(new Set(all).size, '契约清单存在重复键').toBe(all.length);
|
|
54
|
+
for (const key of contract.domains) expect(key.startsWith('--tbox-domain-')).toBe(true);
|
|
55
|
+
for (const key of contract.assets) expect(key.startsWith('--tbox-asset-')).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('motion 族 5 键在清单内(主题轨 T-2.1 契约)', () => {
|
|
59
|
+
for (const key of [
|
|
60
|
+
'--tbox-motion-duration-fast',
|
|
61
|
+
'--tbox-motion-duration-base',
|
|
62
|
+
'--tbox-motion-duration-slow',
|
|
63
|
+
'--tbox-motion-easing-standard',
|
|
64
|
+
'--tbox-motion-easing-emphasized',
|
|
65
|
+
]) {
|
|
66
|
+
expect(contract.platform).toContain(key);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主题接线(单一模板统一化 D8/D9):html[data-theme] 级联单一入口。
|
|
3
|
+
*
|
|
4
|
+
* 纯 CSS 主题包经 `html[data-theme='<name>'] { ... }` 覆写 kit 默认 tokens
|
|
5
|
+
* (specificity 0-1-1 恒胜 :root 0-1-0——主题文件仅需 import 本包 theme 后叠加自身 CSS)。
|
|
6
|
+
* 无 HELLO 下发 / 无运行时热切换(按项目交付语义;多主题热切换为文档化扩展位)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 应用主题:设置 document.documentElement[data-theme](幂等;接线 = import 主题 css(+ 品牌包 brand.js)+ 本函数调用)。 */
|
|
10
|
+
export function applyTheme(themeId: string): void {
|
|
11
|
+
document.documentElement.dataset['theme'] = themeId;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 读当前主题(未设置 → undefined)。 */
|
|
15
|
+
export function currentTheme(): string | undefined {
|
|
16
|
+
return document.documentElement.dataset['theme'];
|
|
17
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 设计 token —— mall-vis 双演示商圈主题(浅色蓝系;⊇ base 26 变量,名字零增减)。
|
|
3
|
+
*
|
|
4
|
+
* C 方案下卡片是 client React 组件、不再有 iframe 下发 tokens,
|
|
5
|
+
* 所以这些设计变量必须落在 client。卡片组件与页面统一用 var(--tbox-*) 引用,
|
|
6
|
+
* 禁止硬编码十六进制色值 / px 字号。
|
|
7
|
+
*
|
|
8
|
+
* 注:这里的 px 会被 postcss-px-to-viewport(750 基准)转成 vw。
|
|
9
|
+
* 视觉源对照与域色矩阵见 docs/reference/templates.md(视觉体系与域色矩阵)。
|
|
10
|
+
*/
|
|
11
|
+
:root {
|
|
12
|
+
/* 主色(mall-vis 蓝系) */
|
|
13
|
+
--tbox-primary: #096bfa;
|
|
14
|
+
--tbox-primary-dark: #2368ec;
|
|
15
|
+
--tbox-primary-light: #eaf3ff;
|
|
16
|
+
--tbox-accent: #096bfa;
|
|
17
|
+
--tbox-accent-light: #eaf3ff;
|
|
18
|
+
--tbox-ink: #0b0f17;
|
|
19
|
+
|
|
20
|
+
/* 文本 */
|
|
21
|
+
--tbox-text-primary: #0b0f17;
|
|
22
|
+
--tbox-text-secondary: #535b6c;
|
|
23
|
+
--tbox-text-muted: #6f7380;
|
|
24
|
+
|
|
25
|
+
/* 背景 */
|
|
26
|
+
--tbox-bg-page: #fbfcfe;
|
|
27
|
+
--tbox-bg-card: #ffffff;
|
|
28
|
+
--tbox-bg-subtle: #f5f7fa;
|
|
29
|
+
--tbox-bg-warm: #f7faff;
|
|
30
|
+
|
|
31
|
+
/* 边框 */
|
|
32
|
+
--tbox-border: #e1e5ec;
|
|
33
|
+
--tbox-border-strong: #d3dae6;
|
|
34
|
+
|
|
35
|
+
/* 阴影 */
|
|
36
|
+
--tbox-shadow-sm: 0 8px 26px rgba(38, 59, 92, 0.05);
|
|
37
|
+
--tbox-shadow-md: 0 12px 32px rgba(24, 82, 177, 0.1);
|
|
38
|
+
--tbox-shadow-lg: 0 14px 34px rgba(2, 46, 122, 0.08);
|
|
39
|
+
|
|
40
|
+
/* 状态色 */
|
|
41
|
+
--tbox-success: #169e5a;
|
|
42
|
+
--tbox-warning: #df8b16;
|
|
43
|
+
--tbox-error: #e42f46;
|
|
44
|
+
|
|
45
|
+
/* 字号——按 750/2x 设计稿基线,对标消息气泡(MessageBubble):
|
|
46
|
+
正文 .msg-markdown / __text = 25px,次要信息(时间/角色) = 18~19px,标题 h1~h4 = 36/32/30/28px。
|
|
47
|
+
注意:rootValue 100 + 根字号脚本会把 px 折半渲染(750 稿 → ≈375 手机列),
|
|
48
|
+
所以这里写的是 2x 值,别按普通 web 的 12/14/16 写(那样渲染出来只有一半、发虚)。 */
|
|
49
|
+
--tbox-fs-heading: 30px; /* ≈气泡 h3:商品名 / 店名 */
|
|
50
|
+
--tbox-fs-body: 25px; /* =气泡正文 */
|
|
51
|
+
--tbox-fs-caption: 20px; /* ≈气泡次要信息(时间/角色) */
|
|
52
|
+
|
|
53
|
+
/* 圆角 */
|
|
54
|
+
--tbox-radius-sm: 12px;
|
|
55
|
+
--tbox-radius-md: 18px;
|
|
56
|
+
--tbox-radius-lg: 48px;
|
|
57
|
+
|
|
58
|
+
/* 内容最大宽度(对齐参考 H5):全宽渐变底 + 内容区 720px 居中。
|
|
59
|
+
大写 PX 不被 postcss-pxtorem 转换(正则大小写敏感),保持固定像素。 */
|
|
60
|
+
--tbox-content-max-width: 720PX;
|
|
61
|
+
/* 页面水平栅格:欢迎区、主卡、快捷入口、输入区、消息气泡和消息卡片共用。 */
|
|
62
|
+
--tbox-page-gutter: 16PX;
|
|
63
|
+
|
|
64
|
+
/* 媒体缩略图 */
|
|
65
|
+
--tbox-media-item-size: 174px;
|
|
66
|
+
--tbox-media-item-radius: 12px;
|
|
67
|
+
|
|
68
|
+
/* 输入框 */
|
|
69
|
+
--tbox-input-bg: rgba(255, 255, 255, 0.7);
|
|
70
|
+
/* 输入条跨过 450PX 根字号阈值时保持固定高度,仅横向伸缩。 */
|
|
71
|
+
--tbox-input-radius: 25PX;
|
|
72
|
+
--tbox-input-padding: 10PX 12PX;
|
|
73
|
+
--tbox-send-btn-size: 22PX;
|
|
74
|
+
--tbox-icon-color: #4b6282;
|
|
75
|
+
|
|
76
|
+
/* 过渡(兼容别名——值引用 motion 族,主题覆写走 --tbox-motion-*) */
|
|
77
|
+
--tbox-transition-fast: var(--tbox-motion-duration-fast) var(--tbox-motion-easing-standard);
|
|
78
|
+
--tbox-transition-base: var(--tbox-motion-duration-base) var(--tbox-motion-easing-standard);
|
|
79
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻触反馈用例:核心契约是「永不影响调用方」——桥失败、API 缺席都必须静默降级。
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({ callJsapi: vi.fn() }));
|
|
7
|
+
vi.mock('@tbox.cn/app-sdk/client', () => ({ callJsapi: mocks.callJsapi }));
|
|
8
|
+
|
|
9
|
+
import { hapticTap, resetHapticBridgeState } from './haptics';
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
mocks.callJsapi.mockReset();
|
|
13
|
+
resetHapticBridgeState();
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('hapticTap', () => {
|
|
18
|
+
it('H1 容器态走 JSAPI 桥的轻档震动', () => {
|
|
19
|
+
mocks.callJsapi.mockResolvedValue({});
|
|
20
|
+
hapticTap();
|
|
21
|
+
expect(mocks.callJsapi).toHaveBeenCalledWith('vibrateShort', { type: 'light' });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('H2 桥不可用 → 回退 navigator.vibrate,且不再重复试桥', async () => {
|
|
25
|
+
const vibrate = vi.fn();
|
|
26
|
+
vi.stubGlobal('navigator', { vibrate });
|
|
27
|
+
mocks.callJsapi.mockRejectedValue({ code: 'BRIDGE_UNAVAILABLE' });
|
|
28
|
+
|
|
29
|
+
hapticTap();
|
|
30
|
+
await vi.waitFor(() => expect(vibrate).toHaveBeenCalledTimes(1));
|
|
31
|
+
|
|
32
|
+
// 第二次不再走桥(避免每次手势都积累一次超时等待)
|
|
33
|
+
hapticTap();
|
|
34
|
+
expect(mocks.callJsapi).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(vibrate).toHaveBeenCalledTimes(2);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('H3 平台无 vibrate API(如 iOS Safari):静默,不抛错', async () => {
|
|
39
|
+
vi.stubGlobal('navigator', {});
|
|
40
|
+
mocks.callJsapi.mockRejectedValue({ code: 'BRIDGE_UNAVAILABLE' });
|
|
41
|
+
expect(() => hapticTap()).not.toThrow();
|
|
42
|
+
await vi.waitFor(() => expect(mocks.callJsapi).toHaveBeenCalled());
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('H4 vibrate 抛错(权限拒绝)也被吞掉', () => {
|
|
46
|
+
vi.stubGlobal('navigator', { vibrate: () => { throw new Error('denied'); } });
|
|
47
|
+
resetHapticBridgeState();
|
|
48
|
+
mocks.callJsapi.mockRejectedValue({ code: 'BRIDGE_UNAVAILABLE' });
|
|
49
|
+
expect(() => hapticTap()).not.toThrow();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻触反馈(haptic tap)——手势跨过阈值那一刻的确认信号。
|
|
3
|
+
*
|
|
4
|
+
* 分层降级,全部 fail-soft:反馈缺失不该影响任何功能路径。
|
|
5
|
+
* 1. 支付宝 web-view 容器:走 JSAPI 桥 my.vibrateShort(type: 'light' 对应轻档)
|
|
6
|
+
* 2. 普通浏览器:navigator.vibrate(Android Chrome 支持;iOS Safari 无此 API,静默跳过)
|
|
7
|
+
*
|
|
8
|
+
* 注意 iOS 上无论哪条路径都可能无感——Safari 不开放 Taptic Engine 给网页,
|
|
9
|
+
* 容器内是否有感取决于宿主 App 的实现。调用方不应依赖它的成功与否。
|
|
10
|
+
*/
|
|
11
|
+
import { callJsapi } from '@tbox.cn/app-sdk/client';
|
|
12
|
+
|
|
13
|
+
/** 极短时长(ms):轻点级别,不是提示级别的长震。 */
|
|
14
|
+
const TAP_DURATION_MS = 10;
|
|
15
|
+
|
|
16
|
+
let bridgeUnavailable = false;
|
|
17
|
+
|
|
18
|
+
/** 触发一次轻触反馈。永不抛错、永不 reject——失败即静默。 */
|
|
19
|
+
export function hapticTap(): void {
|
|
20
|
+
// 桥不可用时不再重试:每次手势都试一遍会积累无意义的超时等待
|
|
21
|
+
if (!bridgeUnavailable) {
|
|
22
|
+
void callJsapi('vibrateShort', { type: 'light' })
|
|
23
|
+
.catch((e: unknown) => {
|
|
24
|
+
if ((e as { code?: string })?.code === 'BRIDGE_UNAVAILABLE') bridgeUnavailable = true;
|
|
25
|
+
vibrateFallback();
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
vibrateFallback();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function vibrateFallback(): void {
|
|
33
|
+
try {
|
|
34
|
+
const nav = typeof navigator === 'undefined' ? null : navigator;
|
|
35
|
+
nav?.vibrate?.(TAP_DURATION_MS);
|
|
36
|
+
} catch {
|
|
37
|
+
/* 权限或平台不支持:静默 */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 单测用:重置桥可用性记忆。 */
|
|
42
|
+
export function resetHapticBridgeState(): void {
|
|
43
|
+
bridgeUnavailable = false;
|
|
44
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createPullDownMachine 全转移表回归(纯逻辑,node 环境):
|
|
3
|
+
* - 实时门控取代 touchStart 锁定(未到顶 move 重锚 → 到顶后从最近锚点测距);
|
|
4
|
+
* - 顶部门槛跨越(pulling→ready→touchEnd shouldLoad)/ 不足回弹 / noMore 封顶 + holdNoMore;
|
|
5
|
+
* - isLoading 全忽略;wheel 累积语义(顶部向上滚累积越阈即发,发后/离顶/向下滚归零);reset 归零。
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
createPullDownMachine,
|
|
10
|
+
DAMPING,
|
|
11
|
+
PULL_DOWN_THRESHOLD,
|
|
12
|
+
PULL_TRIGGER_OFFSET,
|
|
13
|
+
WHEEL_DISTANCE_SCALE,
|
|
14
|
+
WHEEL_TRIGGER_ACCUM,
|
|
15
|
+
} from './pull-down-machine';
|
|
16
|
+
|
|
17
|
+
/** 越过触发线所需的手指行程(阈值按内容位移定义,手指行程 = 位移 / 阻尼)。 */
|
|
18
|
+
const FINGER_OVER = PULL_TRIGGER_OFFSET / DAMPING + 10;
|
|
19
|
+
|
|
20
|
+
describe('createPullDownMachine(touch 路径)', () => {
|
|
21
|
+
it('M1 二段手势:未到顶 move×3 重锚 → 到顶后从最近锚点测距(实时门控)', () => {
|
|
22
|
+
const m = createPullDownMachine();
|
|
23
|
+
m.touchStart(300);
|
|
24
|
+
// 未到顶:重锚 + 状态不动(无 preventDefault)
|
|
25
|
+
expect(m.touchMove({ clientY: 250, isAtTop: false, hasMore: true, isLoading: false })).toEqual({ preventDefault: false });
|
|
26
|
+
expect(m.touchMove({ clientY: 200, isAtTop: false, hasMore: true, isLoading: false })).toEqual({ preventDefault: false });
|
|
27
|
+
m.touchMove({ clientY: 100, isAtTop: false, hasMore: true, isLoading: false });
|
|
28
|
+
// 到顶后:从 100 测距(若未重锚会从 300 测出巨值)
|
|
29
|
+
const r = m.touchMove({ clientY: 130, isAtTop: true, hasMore: true, isLoading: false });
|
|
30
|
+
expect(r.preventDefault).toBe(true);
|
|
31
|
+
expect(m.getState().distance).toBe(30 * DAMPING);
|
|
32
|
+
expect(m.getState().status).toBe('pulling');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('M2 门槛跨越:distance > PULL_TRIGGER_OFFSET → ready;touchEnd 触发 shouldLoad 并进入 loading', () => {
|
|
36
|
+
const m = createPullDownMachine();
|
|
37
|
+
m.touchStart(100);
|
|
38
|
+
m.touchMove({ clientY: 100 + FINGER_OVER, isAtTop: true, hasMore: true, isLoading: false });
|
|
39
|
+
expect(m.getState().status).toBe('ready');
|
|
40
|
+
const end = m.touchEnd({ hasMore: true });
|
|
41
|
+
expect(end).toEqual({ shouldLoad: true, holdNoMore: false });
|
|
42
|
+
expect(m.getState()).toEqual({ status: 'loading', distance: PULL_DOWN_THRESHOLD });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('M2b 触发线以下:保持 pulling,松手不加载(位移≠意图)', () => {
|
|
46
|
+
const m = createPullDownMachine();
|
|
47
|
+
m.touchStart(100);
|
|
48
|
+
// 差一点越线:阈值按内容位移定义,故手指行程取 (触发线 - 1) / 阻尼
|
|
49
|
+
m.touchMove({ clientY: 100 + (PULL_TRIGGER_OFFSET - 1) / DAMPING, isAtTop: true, hasMore: true, isLoading: false });
|
|
50
|
+
expect(m.getState().distance).toBeLessThan(PULL_TRIGGER_OFFSET);
|
|
51
|
+
expect(m.getState().status).toBe('pulling');
|
|
52
|
+
expect(m.touchEnd({ hasMore: true })).toEqual({ shouldLoad: false, holdNoMore: false });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('M3 不足阈值 touchEnd:回弹 idle(shouldLoad=false)', () => {
|
|
56
|
+
const m = createPullDownMachine();
|
|
57
|
+
m.touchStart(100);
|
|
58
|
+
m.touchMove({ clientY: 120, isAtTop: true, hasMore: true, isLoading: false });
|
|
59
|
+
expect(m.getState().status).toBe('pulling');
|
|
60
|
+
expect(m.touchEnd({ hasMore: true })).toEqual({ shouldLoad: false, holdNoMore: false });
|
|
61
|
+
expect(m.getState()).toEqual({ status: 'idle', distance: 0 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('M4 deltaY<=0(上滑):状态回 idle、distance 归零', () => {
|
|
65
|
+
const m = createPullDownMachine();
|
|
66
|
+
m.touchStart(100);
|
|
67
|
+
m.touchMove({ clientY: 140, isAtTop: true, hasMore: true, isLoading: false });
|
|
68
|
+
expect(m.getState().status).toBe('pulling');
|
|
69
|
+
m.touchMove({ clientY: 90, isAtTop: true, hasMore: true, isLoading: false });
|
|
70
|
+
expect(m.getState()).toEqual({ status: 'idle', distance: 0 });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('M5 hasMore=false:noMore 态 + 距离封顶 + touchEnd holdNoMore', () => {
|
|
74
|
+
const m = createPullDownMachine();
|
|
75
|
+
m.touchStart(100);
|
|
76
|
+
m.touchMove({ clientY: 400, isAtTop: true, hasMore: false, isLoading: false });
|
|
77
|
+
expect(m.getState().status).toBe('noMore');
|
|
78
|
+
expect(m.getState().distance).toBe(PULL_DOWN_THRESHOLD); // 封顶
|
|
79
|
+
expect(m.touchEnd({ hasMore: false })).toEqual({ shouldLoad: false, holdNoMore: true });
|
|
80
|
+
expect(m.getState().status).toBe('noMore'); // 保留(wrapper 定时 reset)
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('M6 isLoading=true:touchMove 全忽略', () => {
|
|
84
|
+
const m = createPullDownMachine();
|
|
85
|
+
m.touchStart(100);
|
|
86
|
+
const r = m.touchMove({ clientY: 400, isAtTop: true, hasMore: true, isLoading: true });
|
|
87
|
+
expect(r.preventDefault).toBe(false);
|
|
88
|
+
expect(m.getState()).toEqual({ status: 'idle', distance: 0 });
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('createPullDownMachine(wheel 路径)', () => {
|
|
93
|
+
it('W1 顶部向上滚累积:分次未达阈不触发,越阈触发且归零', () => {
|
|
94
|
+
const m = createPullDownMachine();
|
|
95
|
+
const step = WHEEL_TRIGGER_ACCUM / 2; // 两步刚好到线(不越),第三步越线
|
|
96
|
+
expect(m.wheel({ deltaY: -step, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
97
|
+
expect(m.wheel({ deltaY: -step, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
98
|
+
expect(m.wheel({ deltaY: -10, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: true }); // 越线
|
|
99
|
+
expect(m.wheel({ deltaY: -step, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false }); // 触发后归零重计
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('W1b 单次大 deltaY 不足以触发:滚轮需连续意图(回归「一滚即加载」)', () => {
|
|
103
|
+
const m = createPullDownMachine();
|
|
104
|
+
expect(m.wheel({ deltaY: -(WHEEL_TRIGGER_ACCUM - 1), isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('W2 离顶 / 向下滚:累积清零', () => {
|
|
108
|
+
const m = createPullDownMachine();
|
|
109
|
+
const near = WHEEL_TRIGGER_ACCUM - 10; // 差一点越线
|
|
110
|
+
m.wheel({ deltaY: -near, isAtTop: true, hasMore: true });
|
|
111
|
+
m.wheel({ deltaY: 10, isAtTop: true, hasMore: true }); // 向下滚清零
|
|
112
|
+
expect(m.wheel({ deltaY: -near, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
113
|
+
m.wheel({ deltaY: -near, isAtTop: false, hasMore: true }); // 离顶清零
|
|
114
|
+
expect(m.wheel({ deltaY: -near, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('W3 wheel 驱动与拖拽同尺度的视觉:累积中 pulling,越阈直接进 loading', () => {
|
|
118
|
+
const m = createPullDownMachine();
|
|
119
|
+
const half = WHEEL_TRIGGER_ACCUM / 2;
|
|
120
|
+
m.wheel({ deltaY: -half, isAtTop: true, hasMore: true });
|
|
121
|
+
expect(m.getState().status).toBe('pulling');
|
|
122
|
+
// 累积按触发线之比换算到拖拽尺度,两条路径的文案切换点因此重合
|
|
123
|
+
expect(m.getState().distance).toBeCloseTo(half * WHEEL_DISTANCE_SCALE);
|
|
124
|
+
|
|
125
|
+
m.wheel({ deltaY: -WHEEL_TRIGGER_ACCUM, isAtTop: true, hasMore: true }); // 越线
|
|
126
|
+
// 没有「松手」这一刻,越阈即发:直接进 loading,不经 ready
|
|
127
|
+
expect(m.getState().status).toBe('loading');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('W4 wheelIdle:停止滚动后清掉累积与视觉,文案不卡在拉动态', () => {
|
|
131
|
+
const m = createPullDownMachine();
|
|
132
|
+
const near = WHEEL_TRIGGER_ACCUM - 10;
|
|
133
|
+
m.wheel({ deltaY: -near, isAtTop: true, hasMore: true });
|
|
134
|
+
expect(m.getState().status).toBe('pulling');
|
|
135
|
+
m.wheelIdle();
|
|
136
|
+
expect(m.getState()).toEqual({ status: 'idle', distance: 0 });
|
|
137
|
+
// 累积也已清零:复位后需重新攒够才触发
|
|
138
|
+
expect(m.wheel({ deltaY: -near, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('W4b 加载中 wheel 全忽略:惯性滚动不得把 loading 打回 pulling(动画弹跳根因)', () => {
|
|
142
|
+
const m = createPullDownMachine();
|
|
143
|
+
m.wheel({ deltaY: -(WHEEL_TRIGGER_ACCUM + 10), isAtTop: true, hasMore: true });
|
|
144
|
+
expect(m.getState().status).toBe('loading');
|
|
145
|
+
const during = m.getState();
|
|
146
|
+
|
|
147
|
+
// 触控板惯性在加载期间继续送 wheel
|
|
148
|
+
expect(m.wheel({ deltaY: -200, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
149
|
+
expect(m.getState()).toEqual(during);
|
|
150
|
+
// 显式 isLoading 同样短路
|
|
151
|
+
expect(m.wheel({ deltaY: -200, isAtTop: true, hasMore: true, isLoading: true })).toEqual({ shouldLoad: false });
|
|
152
|
+
expect(m.getState()).toEqual(during);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('W4c wheelIdle 不打断加载:loading 期间只清累积,视觉留给加载完成时的 reset', () => {
|
|
156
|
+
const m = createPullDownMachine();
|
|
157
|
+
m.wheel({ deltaY: -(WHEEL_TRIGGER_ACCUM + 10), isAtTop: true, hasMore: true });
|
|
158
|
+
expect(m.getState().status).toBe('loading');
|
|
159
|
+
m.wheelIdle();
|
|
160
|
+
expect(m.getState().status).toBe('loading'); // 文案不该被打回「历史记录」
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('W5 hasMore=false:wheel 同样进 noMore 且距离封顶,不发加载', () => {
|
|
164
|
+
const m = createPullDownMachine();
|
|
165
|
+
expect(m.wheel({ deltaY: -500, isAtTop: true, hasMore: false })).toEqual({ shouldLoad: false });
|
|
166
|
+
expect(m.getState().status).toBe('noMore');
|
|
167
|
+
expect(m.getState().distance).toBe(PULL_DOWN_THRESHOLD);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('createPullDownMachine(reset)', () => {
|
|
172
|
+
it('R1 reset:status/distance/wheelAccum 全归零(G9 noMore 复活)', () => {
|
|
173
|
+
const m = createPullDownMachine();
|
|
174
|
+
m.touchStart(100);
|
|
175
|
+
m.touchMove({ clientY: 100 + FINGER_OVER, isAtTop: true, hasMore: false, isLoading: false });
|
|
176
|
+
expect(m.getState().status).toBe('noMore');
|
|
177
|
+
m.wheel({ deltaY: -50, isAtTop: true, hasMore: true });
|
|
178
|
+
m.reset();
|
|
179
|
+
expect(m.getState()).toEqual({ status: 'idle', distance: 0 });
|
|
180
|
+
// noMore 复活:reset 后 hasMore=true 的正常下拉可用
|
|
181
|
+
m.touchStart(100);
|
|
182
|
+
m.touchMove({ clientY: 100 + FINGER_OVER, isAtTop: true, hasMore: true, isLoading: false });
|
|
183
|
+
expect(m.getState().status).toBe('ready');
|
|
184
|
+
// wheelAccum 已归零
|
|
185
|
+
expect(m.wheel({ deltaY: -30, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
186
|
+
expect(m.wheel({ deltaY: -30, isAtTop: true, hasMore: true })).toEqual({ shouldLoad: false });
|
|
187
|
+
});
|
|
188
|
+
});
|