@tbox.cn/app-module-member 0.1.0 → 0.8.6
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/AGENTS.md +16 -0
- package/README.md +99 -11
- package/dist/chunk-KEBILLQ5.js +771 -0
- package/dist/chunk-T4SYPQA5.js +1565 -0
- package/dist/client/index.d.ts +11 -2
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +14 -4
- package/dist/login-mascot-5D4X6YZ7.jpg +0 -0
- package/dist/member-QFCIYILN.css +1734 -0
- package/dist/server/index.d.ts +928 -41
- package/dist/server/index.js +13 -3
- package/package.json +25 -16
- package/skills/member/SKILL.md +141 -0
- package/skills/member/references/enrollment.md +34 -0
- package/skills/member/references/points-and-card.md +24 -0
- package/src/client/assets/login-mascot.jpg +0 -0
- package/src/client/assets.d.ts +5 -0
- package/src/client/cards/index.ts +10 -0
- package/src/client/cards/member-card-summary/index.tsx +375 -0
- package/src/client/cards/member-enrollment-cta/index.tsx +61 -0
- package/src/client/cards/member-enrollment-form/index.tsx +181 -0
- package/src/client/cards/member-enrollment-status/index.tsx +65 -0
- package/src/client/cards/member-info/index.tsx +60 -0
- package/src/client/cards/member-login-cta/index.tsx +81 -0
- package/src/client/cards/member-point-usage-list/index.tsx +53 -0
- package/src/client/cards/view.ts +39 -0
- package/src/client/components/ImageWithFallback.tsx +38 -0
- package/src/client/index.ts +21 -4
- package/src/client/styles/member.css +1734 -0
- package/src/client/types/css.d.ts +2 -0
- package/src/server/action.ts +348 -0
- package/src/server/cards/index.ts +31 -0
- package/src/server/cards/member-card-summary/meta.ts +42 -0
- package/src/server/cards/member-card-summary/samples.ts +272 -0
- package/src/server/cards/member-enrollment-cta/meta.ts +19 -0
- package/src/server/cards/member-enrollment-form/meta.ts +26 -0
- package/src/server/cards/member-enrollment-status/meta.ts +18 -0
- package/src/server/cards/member-info/meta.ts +19 -0
- package/src/server/cards/member-login-cta/meta.ts +16 -0
- package/src/server/cards/member-point-usage-list/meta.ts +19 -0
- package/src/server/handler.ts +170 -16
- package/src/server/index.ts +136 -24
- package/src/server/ports.ts +66 -0
- package/src/server/service.ts +188 -36
- package/src/server/status.ts +87 -0
- package/src/server/subject-binding.ts +19 -0
- package/src/server/tool.ts +514 -19
- package/src/server/util.ts +4 -0
- package/tbox.module.json +124 -0
- package/tests/cards-render.test.tsx +526 -0
- package/tests/enrollment.test.ts +121 -0
- package/tests/ports-resolve.test.ts +95 -0
- package/tests/preference-memory.test.ts +86 -0
- package/tests/samples.test.ts +61 -0
- package/tests/service.test.ts +597 -36
- package/tests/skills.test.ts +65 -0
- package/tests/status.test.ts +127 -0
- package/tests/view.test.ts +33 -0
- package/tsup.config.ts +9 -1
- package/dist/chunk-F6FHBH3N.js +0 -160
- package/dist/chunk-OXXT6F5B.js +0 -33
- package/src/client/cards/points/index.tsx +0 -19
- package/src/server/cards/points/meta.ts +0 -22
- package/tbox.component.json +0 -20
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member-card-summary 附加预览样本(沉淀自 mall dev-gallery 会员卡面画廊)。
|
|
3
|
+
*
|
|
4
|
+
* 方法论(迁移自画廊维护约定):
|
|
5
|
+
* - 覆盖基准是组件实际读取的 data 分支(四档 variant / 卡图缺失 / 外链失败 / 跑马灯文案 /
|
|
6
|
+
* 权益图标失败兜底 / 单条偏好 / 历史态),而非 sampleData 的形状;
|
|
7
|
+
* - 视觉等价的输入合并为一格,note 列全合并口径;
|
|
8
|
+
* - 样本即契约:data 必须 dataSchema 校验通过且无 shape 外键(tests/samples.test.ts 双断言)。
|
|
9
|
+
*
|
|
10
|
+
* 素材中立化(画廊时期权益图标指向厂商 CDN,模块样本按仓库铁律净化):
|
|
11
|
+
* - 卡面底图/积分规则长图:无网络依赖的内联 SVG data URI(画廊原实现随行);
|
|
12
|
+
* - 权益图标:内联 SVG 按变体分色——验证的是「图标配色压不压得住卡面底色」与四列网格
|
|
13
|
+
* 换行(低两档 14 项、高两档 15 项的数量差异照实铺),不是图标的图案还原度
|
|
14
|
+
* (真实图标由 provider 运行时下发;图案语义兜底归组件内 ImageWithFallback);
|
|
15
|
+
* - 卡图/图标的外链失败分支刻意保留 example.com 占位(该分支本身是合法预览态)。
|
|
16
|
+
*/
|
|
17
|
+
import type { CardSample } from '@tbox.cn/app-contracts';
|
|
18
|
+
import type { MemberCardSummaryCardData } from '@tbox.cn/app-contracts-mall';
|
|
19
|
+
|
|
20
|
+
/** 卡面底图:底色随 variant 走(前三档浅底深字、black 深底浅字——与模块 CSS 叠字色配对) */
|
|
21
|
+
function cardFace(from: string, to: string): string {
|
|
22
|
+
// 真实卡图恒自带卡名与「我的积分」字样(labelsEmbedded 恒 true),前端只叠积分数字。
|
|
23
|
+
// 两块半透明色块代表那两行字的占位——不写真文字是为了不依赖 SVG 内的中文字体,
|
|
24
|
+
// 要判断的本来也只是「数字有没有压到图上已有元素」。`#` 在 data URI 里须写成 %23。
|
|
25
|
+
const marks
|
|
26
|
+
= '<rect x="58" y="52" width="210" height="42" rx="8" fill="%23000000" opacity="0.14"/>'
|
|
27
|
+
+ '<rect x="58" y="130" width="132" height="26" rx="6" fill="%23000000" opacity="0.10"/>';
|
|
28
|
+
return 'data:image/svg+xml;utf8,'
|
|
29
|
+
+ '<svg xmlns="http://www.w3.org/2000/svg" width="672" height="347">'
|
|
30
|
+
+ '<defs><linearGradient id="f" x1="0" y1="0" x2="1" y2="1">'
|
|
31
|
+
+ `<stop offset="0" stop-color="%23${from}"/><stop offset="1" stop-color="%23${to}"/>`
|
|
32
|
+
+ '</linearGradient></defs>'
|
|
33
|
+
+ '<rect width="672" height="347" fill="url(%23f)"/>'
|
|
34
|
+
+ '<circle cx="548" cy="60" r="150" fill="%23ffffff" opacity="0.16"/>'
|
|
35
|
+
+ marks
|
|
36
|
+
+ '</svg>';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const FACE = {
|
|
40
|
+
blue: cardFace('dce8fb', 'aecdf5'),
|
|
41
|
+
silver: cardFace('eef1f7', 'c4cddf'),
|
|
42
|
+
gold: cardFace('f9ecca', 'e3c283'),
|
|
43
|
+
black: cardFace('1b1e26', '4a412c'),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** 积分规则长图占位(弹层内容):只需一张有高度的图验证弹层滚动与关闭 */
|
|
47
|
+
const POINTS_RULE_IMAGE
|
|
48
|
+
= 'data:image/svg+xml;utf8,'
|
|
49
|
+
+ '<svg xmlns="http://www.w3.org/2000/svg" width="750" height="1600">'
|
|
50
|
+
+ '<rect width="750" height="1600" fill="%23fffdf8"/>'
|
|
51
|
+
+ '<rect x="60" y="80" width="380" height="44" rx="10" fill="%23e2c78f"/>'
|
|
52
|
+
+ '<rect x="60" y="180" width="630" height="24" rx="8" fill="%23ece7dd"/>'
|
|
53
|
+
+ '<rect x="60" y="230" width="560" height="24" rx="8" fill="%23ece7dd"/>'
|
|
54
|
+
+ '<rect x="60" y="280" width="610" height="24" rx="8" fill="%23ece7dd"/>'
|
|
55
|
+
+ '<rect x="60" y="700" width="630" height="24" rx="8" fill="%23ece7dd"/>'
|
|
56
|
+
+ '<rect x="60" y="1500" width="400" height="24" rx="8" fill="%23ece7dd"/>'
|
|
57
|
+
+ '</svg>';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 权益图标占位(按变体分色):圆形徽标 + 随 benefitRef 稳定变化的几何图形。
|
|
61
|
+
* 同一项权益在四个等级下是不同颜色版本的图标——用同一套几何图形配四档配色,
|
|
62
|
+
* 保留「图标配色 vs 卡面底色」这一四档卡面唯一真正要比的东西。
|
|
63
|
+
*/
|
|
64
|
+
const ICON_COLORS: Record<
|
|
65
|
+
NonNullable<MemberCardSummaryCardData['presentation']>['variant'],
|
|
66
|
+
{ fg: string; bg: string }
|
|
67
|
+
> = {
|
|
68
|
+
blue: { fg: '254077', bg: 'dce8fb' },
|
|
69
|
+
silver: { fg: '303443', bg: 'e8ecf3' },
|
|
70
|
+
gold: { fg: '683d08', bg: 'f3e3b8' },
|
|
71
|
+
black: { fg: 'e9d7ac', bg: '2a2d35' },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function benefitIcon(variant: keyof typeof ICON_COLORS, benefitRef: string): string {
|
|
75
|
+
const { fg, bg } = ICON_COLORS[variant];
|
|
76
|
+
// 图形随 benefitRef 散列到四条分支(圆/环/方/三角)——同权益跨档同形、跨权益异形
|
|
77
|
+
let hash = 0;
|
|
78
|
+
for (const ch of benefitRef) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
|
|
79
|
+
const shape = hash % 4;
|
|
80
|
+
const drawn =
|
|
81
|
+
shape === 0
|
|
82
|
+
? `<circle cx="24" cy="24" r="10" fill="%23${fg}"/>`
|
|
83
|
+
: shape === 1
|
|
84
|
+
? `<circle cx="24" cy="24" r="10" fill="none" stroke="%23${fg}" stroke-width="5"/>`
|
|
85
|
+
: shape === 2
|
|
86
|
+
? `<rect x="15" y="15" width="18" height="18" rx="4" fill="%23${fg}"/>`
|
|
87
|
+
: `<path d="M24 12 L36 34 L12 34 Z" fill="%23${fg}"/>`;
|
|
88
|
+
return `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect width="48" height="48" rx="12" fill="%23${bg}"/>${drawn}</svg>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 四档各自的完整权益列表(名称/顺序照画廊采集快照;低两档 14 项,高两档 15 项)。 */
|
|
92
|
+
function benefitsOf(variant: keyof typeof ICON_COLORS): NonNullable<MemberCardSummaryCardData['benefits']> {
|
|
93
|
+
const BASE_NAMES: ReadonlyArray<{ ref: string; name: string; description?: string }> = [
|
|
94
|
+
{ ref: 'benefit:points', name: '消费积分' },
|
|
95
|
+
{ ref: 'benefit:gifts', name: '积分换礼' },
|
|
96
|
+
{ ref: 'benefit:events', name: '会员活动' },
|
|
97
|
+
{ ref: 'benefit:express', name: '顺丰85折' },
|
|
98
|
+
{ ref: 'benefit:points-parking', name: '积分兑停车' },
|
|
99
|
+
{ ref: 'benefit:storage', name: '物品存储' },
|
|
100
|
+
{ ref: 'benefit:care', name: '暖心守护' },
|
|
101
|
+
{ ref: 'benefit:pet', name: '萌宠驿站' },
|
|
102
|
+
{ ref: 'benefit:service', name: '专属客服' },
|
|
103
|
+
{ ref: 'benefit:umbrella', name: '雨伞租借' },
|
|
104
|
+
{ ref: 'benefit:wheelchair', name: '轮椅租借' },
|
|
105
|
+
{ ref: 'benefit:stroller', name: '婴儿车租借' },
|
|
106
|
+
{ ref: 'benefit:birthday', name: '生日礼遇' },
|
|
107
|
+
{ ref: 'benefit:wifi', name: '免费WIFI' },
|
|
108
|
+
];
|
|
109
|
+
const items = BASE_NAMES.map((b) => ({
|
|
110
|
+
benefitRef: b.ref,
|
|
111
|
+
name: b.name,
|
|
112
|
+
iconUrl: benefitIcon(variant, b.ref),
|
|
113
|
+
}));
|
|
114
|
+
// 高两档多出的正是停车那项(金:免 1 小时 / 黑:免 2 小时)——数量差异是卡面该体现的东西
|
|
115
|
+
if (variant === 'gold' || variant === 'black') {
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
benefitRef: 'benefit:parking',
|
|
119
|
+
name: '免费停车',
|
|
120
|
+
iconUrl: benefitIcon(variant, 'benefit:parking'),
|
|
121
|
+
...(variant === 'gold' ? { description: '可免1小时' } : { description: '可免2小时' }),
|
|
122
|
+
},
|
|
123
|
+
...items,
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
return items;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const PROGRESS_SHORT = {
|
|
130
|
+
daySpentCents: 12_345,
|
|
131
|
+
yearSpentCents: 500_000,
|
|
132
|
+
percent: 25,
|
|
133
|
+
message: '今日已消费123.45元',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const samples: CardSample<MemberCardSummaryCardData>[] = [
|
|
137
|
+
{
|
|
138
|
+
label: '卡面·金卡',
|
|
139
|
+
note: '基准形态:卡名与「我的积分」由卡图自带,前端只叠积分数。带 pointsRuleImageUrl 时触发器是盖在卡图自印按钮上的透明热区(视觉上看不出,弹层态需手动点它)',
|
|
140
|
+
group: '会员卡面(四档 variant)',
|
|
141
|
+
data: {
|
|
142
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
143
|
+
availablePoints: 860, cardImageUrl: FACE.gold, pointsRuleImageUrl: POINTS_RULE_IMAGE,
|
|
144
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
145
|
+
progress: PROGRESS_SHORT, benefits: benefitsOf('gold'),
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
label: '卡面·蓝卡',
|
|
150
|
+
note: 'variant=blue(入门等级)——叠字对比度之外,权益 14 项且无「免费停车」,与金/黑的 15 项对照',
|
|
151
|
+
group: '会员卡面(四档 variant)',
|
|
152
|
+
data: {
|
|
153
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '悦享卡', status: 'active',
|
|
154
|
+
availablePoints: 120, cardImageUrl: FACE.blue,
|
|
155
|
+
presentation: { variant: 'blue', labelsEmbedded: true },
|
|
156
|
+
progress: { daySpentCents: 0, yearSpentCents: 8_800, percent: 4, message: '年累计已消费88.00元' },
|
|
157
|
+
benefits: benefitsOf('blue'),
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
label: '卡面·银卡',
|
|
162
|
+
note: 'variant=silver——浅底最容易压不住白字;权益同为 14 项,图标是银卡配色版',
|
|
163
|
+
group: '会员卡面(四档 variant)',
|
|
164
|
+
data: {
|
|
165
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '银卡会员', status: 'active',
|
|
166
|
+
availablePoints: 3_400, cardImageUrl: FACE.silver,
|
|
167
|
+
presentation: { variant: 'silver', labelsEmbedded: true },
|
|
168
|
+
progress: { daySpentCents: 4_500, yearSpentCents: 120_000, percent: 48, message: '今日已消费45.00元,年累计已消费1200.00元' },
|
|
169
|
+
benefits: benefitsOf('silver'),
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
label: '卡面·黑金卡',
|
|
174
|
+
note: 'variant=black(最高等级)——金色描边与深底的层次;15 项权益,免费停车带「可免2小时」说明',
|
|
175
|
+
group: '会员卡面(四档 variant)',
|
|
176
|
+
data: {
|
|
177
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '黑金会员', status: 'active',
|
|
178
|
+
availablePoints: 128_600, cardImageUrl: FACE.black,
|
|
179
|
+
presentation: { variant: 'black', labelsEmbedded: true },
|
|
180
|
+
progress: { daySpentCents: 98_800, yearSpentCents: 2_400_000, percent: 100, message: '年累计已消费24000.00元,已达最高等级' },
|
|
181
|
+
benefits: benefitsOf('black'),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
label: '卡面·卡图缺失',
|
|
186
|
+
note: 'presentation 在但 cardImageUrl 缺省——退到渐变兜底底纹,叠字排布应不变',
|
|
187
|
+
group: '卡面素材分支',
|
|
188
|
+
data: {
|
|
189
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
190
|
+
availablePoints: 860,
|
|
191
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
192
|
+
progress: PROGRESS_SHORT, benefits: benefitsOf('gold'),
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
label: '卡面·外链加载失败',
|
|
197
|
+
note: '线上卡图 404 / 断网的实况——ImageWithFallback 占位不应把卡面撑变形',
|
|
198
|
+
group: '卡面素材分支',
|
|
199
|
+
data: {
|
|
200
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
201
|
+
availablePoints: 860, cardImageUrl: 'https://assets.example.com/member/gold-card.png',
|
|
202
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
203
|
+
progress: PROGRESS_SHORT, benefits: benefitsOf('gold'),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
label: '卡面·进度长文案',
|
|
208
|
+
note: 'progress.message 超 24 字触发 --scroll 跑马灯;percent=0 时进度条应可见轨道',
|
|
209
|
+
group: '卡面素材分支',
|
|
210
|
+
data: {
|
|
211
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
212
|
+
availablePoints: 860, cardImageUrl: FACE.gold,
|
|
213
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
214
|
+
progress: {
|
|
215
|
+
daySpentCents: 0, yearSpentCents: 0, percent: 0,
|
|
216
|
+
message: '今年还没有消费记录,累计消费满5000.00元可升级为银卡会员',
|
|
217
|
+
},
|
|
218
|
+
benefits: benefitsOf('gold'),
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
label: '卡面·无进度无权益',
|
|
223
|
+
note: 'progress 与 benefits 同时缺省——只剩卡面 + 偏好引导,检查底部收口',
|
|
224
|
+
group: '卡面素材分支',
|
|
225
|
+
data: {
|
|
226
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '悦享卡', status: 'active',
|
|
227
|
+
availablePoints: 0, cardImageUrl: FACE.blue,
|
|
228
|
+
presentation: { variant: 'blue', labelsEmbedded: true },
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
label: '卡面·权益图标失败',
|
|
233
|
+
note: 'iconUrl 加载失败 → 按权益名匹配的兜底图标(停车/积分/活动/其它四条分支)',
|
|
234
|
+
group: '卡面素材分支',
|
|
235
|
+
data: {
|
|
236
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
237
|
+
availablePoints: 860, cardImageUrl: FACE.gold,
|
|
238
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
239
|
+
progress: PROGRESS_SHORT,
|
|
240
|
+
benefits: [
|
|
241
|
+
{ benefitRef: 'b1', name: '免费停车', iconUrl: 'https://assets.example.com/x.png' },
|
|
242
|
+
{ benefitRef: 'b2', name: '消费积分', iconUrl: 'https://assets.example.com/x.png' },
|
|
243
|
+
{ benefitRef: 'b3', name: '会员活动', iconUrl: 'https://assets.example.com/x.png' },
|
|
244
|
+
{ benefitRef: 'b4', name: '生日礼遇', iconUrl: 'https://assets.example.com/x.png' },
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
label: '卡面·单条偏好',
|
|
250
|
+
note: 'preferencePrompts 显式给一项——缺省时组件兜底成 contracts 固定三项,单项时看按钮是否被拉伸',
|
|
251
|
+
group: '偏好引导分支',
|
|
252
|
+
data: {
|
|
253
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
254
|
+
availablePoints: 860, cardImageUrl: FACE.gold,
|
|
255
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
256
|
+
progress: PROGRESS_SHORT, benefits: benefitsOf('gold'),
|
|
257
|
+
preferencePrompts: [{ value: '我常来吃美食', query: '推荐值得去的餐饮美食门店' }],
|
|
258
|
+
},
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
label: '卡面·历史卡',
|
|
262
|
+
note: 'isHistory——偏好引导按钮全禁用(点了也不会发消息),卡面与权益区照常渲染',
|
|
263
|
+
group: '偏好引导分支',
|
|
264
|
+
data: {
|
|
265
|
+
userId: 'u_1001', cardRef: 'card:u_1001', cardDisplay: '金卡会员', status: 'active',
|
|
266
|
+
availablePoints: 860, cardImageUrl: FACE.gold,
|
|
267
|
+
presentation: { variant: 'gold', labelsEmbedded: true },
|
|
268
|
+
progress: PROGRESS_SHORT, benefits: benefitsOf('gold'),
|
|
269
|
+
},
|
|
270
|
+
isHistory: true,
|
|
271
|
+
},
|
|
272
|
+
];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberEnrollmentCtaCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberEnrollmentCtaCardDataSchema> = {
|
|
5
|
+
cardType: 'member-enrollment-cta',
|
|
6
|
+
dataSchema: memberEnrollmentCtaCardDataSchema,
|
|
7
|
+
displayName: '开卡入会',
|
|
8
|
+
description: '开卡入会引导卡——点击后跳转 alipays 链接完成入会',
|
|
9
|
+
sampleData: {
|
|
10
|
+
userId: 'u_1001',
|
|
11
|
+
authState: 'authenticated',
|
|
12
|
+
state: 'not_enrolled',
|
|
13
|
+
enrollUrl: 'alipays://platformapi/startapp?appId=demo&page=v/index/index',
|
|
14
|
+
title: '入会后继续',
|
|
15
|
+
},
|
|
16
|
+
schemaVersion: 2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberEnrollmentFormCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberEnrollmentFormCardDataSchema> = {
|
|
5
|
+
cardType: 'member-enrollment-form',
|
|
6
|
+
dataSchema: memberEnrollmentFormCardDataSchema,
|
|
7
|
+
displayName: '入会表单',
|
|
8
|
+
description: '会员入会表单(支付宝授权预填 + 手动降级;动作 member.request-alipay-authorization / member.enroll)',
|
|
9
|
+
sampleData: {
|
|
10
|
+
userId: 'u_1001',
|
|
11
|
+
state: 'not_enrolled',
|
|
12
|
+
formVersion: 'v2026.08',
|
|
13
|
+
fields: [
|
|
14
|
+
{ fieldId: 'mobile', label: '手机号', type: 'tel', required: true, placeholder: '请输入手机号' },
|
|
15
|
+
{ fieldId: 'displayName', label: '姓名', type: 'text', required: false },
|
|
16
|
+
],
|
|
17
|
+
consents: [
|
|
18
|
+
{ consentId: 'privacy', version: '1', title: '隐私政策', required: true },
|
|
19
|
+
{ consentId: 'membership-terms', version: '1', title: '会员服务条款', required: false },
|
|
20
|
+
],
|
|
21
|
+
authorization: { status: 'required' },
|
|
22
|
+
},
|
|
23
|
+
schemaVersion: 2,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export default meta;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberEnrollmentStatusCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberEnrollmentStatusCardDataSchema> = {
|
|
5
|
+
cardType: 'member-enrollment-status',
|
|
6
|
+
dataSchema: memberEnrollmentStatusCardDataSchema,
|
|
7
|
+
displayName: '入会状态',
|
|
8
|
+
description: '入会结果状态(pending 态附显式刷新按钮,动作 member.query-enrollment-status)',
|
|
9
|
+
sampleData: {
|
|
10
|
+
userId: 'u_1001',
|
|
11
|
+
attemptRef: 'att_1',
|
|
12
|
+
state: 'pending_confirmation',
|
|
13
|
+
message: '厂商确认中,可稍后再刷新',
|
|
14
|
+
},
|
|
15
|
+
schemaVersion: 2,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default meta;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberInfoCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberInfoCardDataSchema> = {
|
|
5
|
+
cardType: 'member-info',
|
|
6
|
+
dataSchema: memberInfoCardDataSchema,
|
|
7
|
+
displayName: '会员信息',
|
|
8
|
+
description: '会员账户状态与个人资料',
|
|
9
|
+
sampleData: {
|
|
10
|
+
userId: 'u_1001',
|
|
11
|
+
authState: 'authenticated',
|
|
12
|
+
state: 'active',
|
|
13
|
+
displayName: '示例会员',
|
|
14
|
+
labels: [{ labelRef: 'member', name: '会员' }],
|
|
15
|
+
},
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberLoginCtaCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberLoginCtaCardDataSchema> = {
|
|
5
|
+
cardType: 'member-login-cta',
|
|
6
|
+
dataSchema: memberLoginCtaCardDataSchema,
|
|
7
|
+
displayName: '登录引导',
|
|
8
|
+
description: '未登录引导卡——点击跳转商场小程序登录页,登录后回到智能体继续',
|
|
9
|
+
sampleData: {
|
|
10
|
+
loginUrl: 'alipays://platformapi/startapp?appId=demo&page=v/login/index',
|
|
11
|
+
title: '登录后继续',
|
|
12
|
+
},
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CardMeta } from '@tbox.cn/app-contracts';
|
|
2
|
+
import { memberPointUsageListCardDataSchema } from '@tbox.cn/app-contracts-mall';
|
|
3
|
+
|
|
4
|
+
const meta: CardMeta<typeof memberPointUsageListCardDataSchema> = {
|
|
5
|
+
cardType: 'member-point-usage-list',
|
|
6
|
+
dataSchema: memberPointUsageListCardDataSchema,
|
|
7
|
+
displayName: '积分余额与流水',
|
|
8
|
+
description: '会员积分余额、厂商会员标签与变动流水(吸收教学 points 卡)',
|
|
9
|
+
sampleData: {
|
|
10
|
+
userId: 'u_1001',
|
|
11
|
+
balance: 860,
|
|
12
|
+
membershipLabel: '悦享卡',
|
|
13
|
+
items: [{ usageRecordRef: 'r1', title: '消费奖励', amount: '+100', usedAt: '2026-08-13T00:00:00Z' }],
|
|
14
|
+
total: 1,
|
|
15
|
+
},
|
|
16
|
+
schemaVersion: 1,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default meta;
|
package/src/server/handler.ts
CHANGED
|
@@ -1,24 +1,178 @@
|
|
|
1
|
-
import type { HandlerDefinition } from '@tbox.cn/app-sdk/server';
|
|
2
|
-
import type { MemberQueryPort } from '@tbox.cn/app-contracts-mall';
|
|
3
|
-
import type { ServerContext } from '@tbox.cn/app-sdk/server';
|
|
4
|
-
|
|
5
1
|
/**
|
|
6
|
-
* member handler
|
|
7
|
-
*
|
|
2
|
+
* member handler(011 K2;015:写动作拆至 action.ts,此处只保留意图路径出卡)。
|
|
3
|
+
* - 查积分/信息/卡 → 出对应卡(LLM 路径走同名工具)。
|
|
4
|
+
* - 会员偏好长期记忆(017 PRD):三项声明式 intent → ctx.memory 保存 + continueWith 续接导购。
|
|
5
|
+
* - 上下文统一化:userId 来源 = 运行身份(reqCtx.identity.userId,handler 第四参)——
|
|
6
|
+
* 旧 payload.userId 是模型注入面(P4-3),已删除;偏好分支同经 reqCtx 显式读取。
|
|
8
7
|
*/
|
|
8
|
+
import type { HandlerDefinition, ServerContext } from '@tbox.cn/app-sdk/server';
|
|
9
|
+
import {
|
|
10
|
+
MEMBER_PREFERENCE_PROMPTS,
|
|
11
|
+
MEMBER_PREFERENCE_RECOMMENDATION_QUERIES,
|
|
12
|
+
MEMBER_PREFERENCE_VALUES,
|
|
13
|
+
parseMallScope,
|
|
14
|
+
} from '@tbox.cn/app-contracts-mall';
|
|
15
|
+
import type { MemberPreferenceValue } from '@tbox.cn/app-contracts-mall';
|
|
16
|
+
import { MEMBER_QUERY_NOTICES, resolveMemberStatus } from './status';
|
|
17
|
+
import { memberPrivacyRefusal } from './tool';
|
|
18
|
+
import { resolveMemberBenefitsPort, resolveMemberCardPort } from './ports';
|
|
19
|
+
|
|
20
|
+
function matchMemberPreference(content: string) {
|
|
21
|
+
return MEMBER_PREFERENCE_PROMPTS.find((prompt) => content === prompt.query);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 偏好确认回执文案(第二人称):按钮值是用户口吻(写记忆保留第一人称),
|
|
25
|
+
* 助手复述须转换人称;Record 穷尽性保证枚举扩容时编译期强制补齐。 */
|
|
26
|
+
const MEMBER_PREFERENCE_ACK_TEXTS: Record<MemberPreferenceValue, string> = {
|
|
27
|
+
'我常来买衣服': '你常来买衣服',
|
|
28
|
+
'我常带娃来玩': '你常带娃来玩',
|
|
29
|
+
'我常来吃美食': '你常来吃美食',
|
|
30
|
+
};
|
|
31
|
+
|
|
9
32
|
export function createMemberHandler(ctx: ServerContext): HandlerDefinition {
|
|
10
33
|
return {
|
|
11
34
|
id: 'member',
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
// match:'exact':偏好按钮发出的是固定整句值(member-card-summary 按钮组),
|
|
36
|
+
// 整句全等命中——不入 BM25 词袋(相似查询「2楼有啥美食可逛」曾被词袋误吞,F3)
|
|
37
|
+
intents: [{ patterns: [...MEMBER_PREFERENCE_VALUES], match: 'exact' }],
|
|
38
|
+
handle: async (action, payload, _ctx, reqCtx) => {
|
|
39
|
+
if (action === 'intent') {
|
|
40
|
+
const content = typeof (payload as { content?: unknown } | undefined)?.content === 'string'
|
|
41
|
+
? (payload as { content: string }).content.trim()
|
|
42
|
+
: '';
|
|
43
|
+
const prompt = matchMemberPreference(content);
|
|
44
|
+
// 洋葱定律 1:未识别 = 失败 = 必须放行(不再出 12 字死文案短路整轮)。
|
|
45
|
+
// exact 路由下正常不可达(全等已保证命中),防御性兜底同样放行 LLM 正常对话。
|
|
46
|
+
if (!prompt) return { cards: [], continueWith: { content } };
|
|
47
|
+
|
|
48
|
+
// 上下文统一化:运行身份 = handle 第四参 reqCtx(引擎意图分发注入)
|
|
49
|
+
const identity = reqCtx?.identity;
|
|
50
|
+
if (!identity || identity.source === 'anonymous') {
|
|
51
|
+
return { cards: [], text: '登录后才能保存会员偏好' };
|
|
52
|
+
}
|
|
53
|
+
if (!ctx.memory || ctx.memory.capabilities?.save === false) {
|
|
54
|
+
return { cards: [], text: '偏好记忆暂不可用,请稍后再试' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const mallId = parseMallScope(reqCtx?.attributes)?.mallId;
|
|
58
|
+
await ctx.memory.save({
|
|
59
|
+
userId: identity.userId,
|
|
60
|
+
...(mallId ? { scope: { mallId } } : {}),
|
|
61
|
+
kind: 'preference',
|
|
62
|
+
content: `会员偏好:${prompt.value}`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
cards: [],
|
|
67
|
+
text: `记住啦,${MEMBER_PREFERENCE_ACK_TEXTS[prompt.value]}。正在为你查找相关的门店和导购服务,请稍等。\n\n`,
|
|
68
|
+
continueWith: { content: MEMBER_PREFERENCE_RECOMMENDATION_QUERIES[prompt.value] },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!reqCtx) return { cards: [], text: '会员服务不可用(运行上下文缺失)' };
|
|
73
|
+
const query = typeof (payload as { query?: unknown } | undefined)?.query === 'string'
|
|
74
|
+
? (payload as { query: string }).query
|
|
75
|
+
: '';
|
|
76
|
+
const privacyRefusal = memberPrivacyRefusal(query);
|
|
77
|
+
if (privacyRefusal) return { cards: [], text: privacyRefusal };
|
|
78
|
+
const status = await resolveMemberStatus(reqCtx, ctx);
|
|
79
|
+
if (status.outcome === 'active') {
|
|
80
|
+
const userId = reqCtx.identity.userId;
|
|
81
|
+
switch (action) {
|
|
82
|
+
case 'query-points': {
|
|
83
|
+
// 017 槽位原子化:active 态按槽解析(card 面 + benefits 面;多次构造廉价)。
|
|
84
|
+
// 槽解析失败 → unavailable 收口(旧防御分支语义延续:不裸抛、不答「积分 0」)
|
|
85
|
+
let cardPort: Awaited<ReturnType<typeof resolveMemberCardPort>>;
|
|
86
|
+
let benefitsPort: Awaited<ReturnType<typeof resolveMemberBenefitsPort>>;
|
|
87
|
+
try {
|
|
88
|
+
[cardPort, benefitsPort] = await Promise.all([
|
|
89
|
+
resolveMemberCardPort(reqCtx, ctx),
|
|
90
|
+
resolveMemberBenefitsPort(reqCtx, ctx),
|
|
91
|
+
]);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
ctx.logger?.warn(`member handler:active 槽解析失败:err=${(error as Error).message}`);
|
|
94
|
+
return { cards: [], text: MEMBER_QUERY_NOTICES.unavailable };
|
|
95
|
+
}
|
|
96
|
+
const card = await cardPort.getCard(userId);
|
|
97
|
+
let points = { userId, balance: 0 } as Awaited<ReturnType<typeof benefitsPort.queryPoints>>;
|
|
98
|
+
let pointsUnavailable = false;
|
|
99
|
+
try {
|
|
100
|
+
points = await benefitsPort.queryPoints(userId);
|
|
101
|
+
} catch {
|
|
102
|
+
pointsUnavailable = true;
|
|
103
|
+
}
|
|
104
|
+
if (card?.presentation !== undefined) {
|
|
105
|
+
let usage: Awaited<ReturnType<typeof benefitsPort.listPointUsage>> | undefined;
|
|
106
|
+
let usageUnavailable = false;
|
|
107
|
+
if (card.pointsUrl) {
|
|
108
|
+
try {
|
|
109
|
+
usage = await benefitsPort.listPointUsage(userId, 1, 10);
|
|
110
|
+
} catch {
|
|
111
|
+
usageUnavailable = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const cardData = {
|
|
115
|
+
userId,
|
|
116
|
+
cardRef: card.cardRef,
|
|
117
|
+
cardDisplay: card.cardDisplay,
|
|
118
|
+
status: card.status,
|
|
119
|
+
...(card.levelName ? { levelName: card.levelName } : {}),
|
|
120
|
+
availablePoints: points.balance,
|
|
121
|
+
...(card.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
|
|
122
|
+
...(card.pointsRuleImageUrl ? { pointsRuleImageUrl: card.pointsRuleImageUrl } : {}),
|
|
123
|
+
...(card.pointsRuleContent ? { pointsRuleContent: card.pointsRuleContent } : {}),
|
|
124
|
+
...(card.pointsUrl ? { pointsUrl: card.pointsUrl } : {}),
|
|
125
|
+
...(card.presentation ? { presentation: card.presentation } : {}),
|
|
126
|
+
...(card.progress ? { progress: card.progress } : {}),
|
|
127
|
+
...(card.benefits ? { benefits: [...card.benefits] } : {}),
|
|
128
|
+
} as Record<string, unknown>;
|
|
129
|
+
return {
|
|
130
|
+
cards: [{ cardType: 'member-card-summary', data: cardData }],
|
|
131
|
+
text: pointsUnavailable
|
|
132
|
+
? '会员卡可正常查看,但当前积分余额暂不可用,暂无最近积分流水记录'
|
|
133
|
+
: usageUnavailable
|
|
134
|
+
? `当前积分 ${points.balance},暂无最近积分流水记录`
|
|
135
|
+
: usage && usage.items.length > 0
|
|
136
|
+
? `当前积分 ${points.balance},最近流水:${usage.items.map((item) => `${item.title}${item.amount}分${item.usedAt ? `(${item.usedAt})` : ''}`).join(';')}`
|
|
137
|
+
: `当前积分 ${points.balance},暂未查询到最近积分流水`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const usage = await benefitsPort.listPointUsage(userId, 1, 10);
|
|
141
|
+
const usageText = usage.items.length > 0
|
|
142
|
+
? `,最近流水:${usage.items.map((item) => `${item.title}${item.amount}分${item.usedAt ? `(${item.usedAt})` : ''}`).join(';')}`
|
|
143
|
+
: ',暂未查询到最近积分流水';
|
|
144
|
+
if (!card) {
|
|
145
|
+
return {
|
|
146
|
+
cards: [],
|
|
147
|
+
text: `当前积分 ${points.balance}${points.membershipLabel ? `,会员卡 ${points.membershipLabel}` : ''}${usageText}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
cards: [{ cardType: 'member-card-summary', data: {
|
|
152
|
+
userId,
|
|
153
|
+
cardRef: card.cardRef,
|
|
154
|
+
cardDisplay: card.cardDisplay,
|
|
155
|
+
status: card.status,
|
|
156
|
+
...(card.levelName ? { levelName: card.levelName } : {}),
|
|
157
|
+
availablePoints: points.balance,
|
|
158
|
+
...(card.cardImageUrl ? { cardImageUrl: card.cardImageUrl } : {}),
|
|
159
|
+
...(card.pointsRuleImageUrl ? { pointsRuleImageUrl: card.pointsRuleImageUrl } : {}),
|
|
160
|
+
...(card.pointsRuleContent ? { pointsRuleContent: card.pointsRuleContent } : {}),
|
|
161
|
+
...(card.pointsUrl ? { pointsUrl: card.pointsUrl } : {}),
|
|
162
|
+
...(card.presentation ? { presentation: card.presentation } : {}),
|
|
163
|
+
...(card.progress ? { progress: card.progress } : {}),
|
|
164
|
+
...(card.benefits ? { benefits: [...card.benefits] } : {}),
|
|
165
|
+
} as Record<string, unknown> }],
|
|
166
|
+
text: `当前积分 ${points.balance}${points.membershipLabel ? `,会员卡 ${points.membershipLabel}` : ''}${usageText}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
default: {
|
|
170
|
+
return { cards: [], text: '不支持的会员操作' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// 非 active:门禁文案与工具路径同源(意图路径不再对未入会/未登录答「积分 0」)
|
|
175
|
+
return { cards: [], text: MEMBER_QUERY_NOTICES[status.outcome] };
|
|
22
176
|
},
|
|
23
177
|
};
|
|
24
178
|
}
|