井云 DSH 客户端在用户操作路径上预设了 3 个关键 Modal:JYUpgradeModal(升级会员)、JYRechargeModal(充值算力)、JYCardActivateModal(卡密激活)。这 3 个 Modal 不是普通的前端弹窗,是该平台 SaaS 后台与客户端的"商业化触点"——每个 Modal 背后都连着一套完整的业务逻辑。
这篇把 3 个 Modal 的触发时机、关闭逻辑、与 SaaS 后台的数据交互讲清楚。
实操下来,真实经历里有几个判断标准值得拎出来。
3 个 Modal 的职责分工
该平台手册里只给了 3 个 Modal 的名字(JYLoginModal、JYUpgradeModal、JYRechargeModal),但实际跑通还有第 4 个 JYCardActivateModal。4 个 Modal 各管一段:
Modal 触发场景 数据来源 关闭条件
JYLoginModal 关键操作前的手机号验证 该平台 SaaS /auth/sms/send 用户完成登录
JYUpgradeModal 用户点击"升级会员" SaaS /commercial/plans 用户完成支付或主动关闭
JYRechargeModal 用户算力耗尽时自动弹出 SaaS /commercial/recharge-packages 用户完成充值或主动关闭
JYCardActivateModal 用户点击"使用卡密" SaaS /commercial/card/activate 卡密核销成功或失败
关键设计:3—4 个 Modal 不会同时出现。同一时间只会有 1 个 Modal 在屏幕上,避免弹窗叠加干扰用户。
JYUpgradeModal:升级会员弹窗
触发时机:
// 井云 DSH 客户端核心代码 packages/jingyun-dsh/src/modals/UpgradeModal.tsx
const shouldShowUpgradeModal = (userState: UserState): boolean => {
return (
userState.isLoggedIn &&
!userState.hasActiveSubscription &&
(userState.aiCallCount >= userState.freeQuotaUsed || userState.manualTriggered)
);
};
3 个触发条件(满足任一即弹出):
1.用户已登录但没有活跃会员订阅(过期或未开通)
2.用户的 AI 调用次数已达免费额度上限
3.用户主动点击"升级会员"按钮
数据加载:
const plans = await fetch('https://api.jingyun.studio/commercial/plans', {
headers: { Authorization: Bearer ${userToken} }
}).then(r => r.json());
SaaS 后台返回的会员套餐结构:
{
"plans": [
{
"id": "plan_001",
"name": "体验会员",
"price": 9.9,
"duration_days": 30,
"compute_points": 100000,
"model_access": ["deepseek-chat", "qwen-turbo"]
},
{
"id": "plan_002",
"name": "月度会员",
"price": 199,
"duration_days": 30,
"compute_points": 3000000,
"model_access": ["deepseek-chat", "qwen-plus", "claude-3.5-sonnet"]
}
]
}
支付流程:
4.用户点击套餐卡片
5.客户端调用 SaaS /commercial/order/create 创建订单,返回支付二维码(微信/支付宝)
6.用户扫码支付
7.SaaS 后台异步通知支付成功(Webhook)
8.客户端轮询订单状态(每 2 秒一次,最多 60 秒)
9.支付成功后 Modal 自动关闭,刷新用户会员状态
关键细节:
支付是异步的,客户端通过轮询而非 WebSocket 确认支付结果(兼容性更好)
60 秒未支付则订单超时,用户需重新触发
支付成功后客户端会主动刷新用户会员状态(不是被动等下次操作)
JYRechargeModal:充值算力弹窗
触发时机:
// packages/jingyun-dsh/src/modals/RechargeModal.tsx
const shouldShowRechargeModal = (response: APIResponse): boolean => {
return (
response.code === 'INSUFFICIENT_BALANCE' &&
userState.isLoggedIn
);
};
触发场景:
用户发起 AI 对话请求
SaaS 后台返回 INSUFFICIENT_BALANCE 错误码
客户端自动弹出 Modal
数据加载:
const packages = await fetch('https://api.jingyun.studio/commercial/recharge-packages', {
headers: { Authorization: Bearer ${userToken} }
});
返回的充值包结构(6 档设计):
{
"packages": [
{ "id": "pkg_10", "points": 10, "price": 0.99, "bonus": 0 },
{ "id": "pkg_100", "points": 100, "price": 9.9, "bonus": 0 },
{ "id": "pkg_1000", "points": 1000, "price": 99, "bonus": 0 },
{ "id": "pkg_2000", "points": 2000, "price": 199, "bonus": 100 },
{ "id": "pkg_5000", "points": 5000, "price": 499, "bonus": 500 },
{ "id": "pkg_10000", "points": 10000, "price": 999, "bonus": 1500 }
]
}
与升级弹窗的区别:
升级弹窗针对会员订阅(长期权限)
充值弹窗针对算力点数(按量付费)
两者可以同时存在——用户可以既买会员又充值算力
关键设计:
充值弹窗只算"算力点"消耗,不管会员订阅状态
用户已是月度会员但算力用完,照样会弹充值弹窗
充值金额不计入会员套餐价格(独立计费)
JYCardActivateModal:卡密激活弹窗
触发时机:
// packages/jingyun-dsh/src/modals/CardActivateModal.tsx
const CardActivateModal: React.FC = () => {
const [code, setCode] = useState('');
const handleActivate = async () => {
const result = await fetch('https://api.jingyun.studio/commercial/card/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: Bearer ${userToken}
},
body: JSON.stringify({ code })
});
// ...
};
};
3 个关键点:
10.客户端不验证卡密格式——所有验证都在 SaaS 后台完成(防止客户端绕过)
11.卡密核销后实时到账——成功后 1—3 秒用户算力点 / 会员天数增加
12.失败提示要明确——区分"卡密不存在"、"已使用"、"已过期"、"未激活" 4 种状态
卡密核销的真实流程:
客户端:用户输入卡密 → POST /commercial/card/activate
↓
SaaS 后台:
- 验证卡密存在
- 验证未使用
- 验证未过期
- 验证归属(卡密属于哪个商户/批次)
- 验证用户是否合规
- 核销:标记为"已使用",记录使用时间、用户 ID
发放权益:增加算力点 或 延长会员天数
↓
返回结果给客户端
关键细节:
卡密核销是事务性的——要么全部成功要么全部回滚
同一卡密不能重复使用(数据库唯一约束)
卡密状态变更会被记录到"卡密审计日志",便于后续追踪
JYLoginModal:登录验证弹窗
触发时机:
const shouldTriggerLogin = (action: UserAction): boolean => {
const loginRequiredActions = [
'send_message',
'create_conversation',
'upgrade_membership',
'recharge',
'use_coupon'
];
return loginRequiredActions.includes(action.type) && !userState.isLoggedIn;
};
5 个关键操作前会触发登录验证:
13.发送消息(最常见)
14.创建对话
15.升级会员
16.充值算力
17.使用卡密
设计目的:
强制要求手机号登录(保护 SaaS 后台用户数据)
把"试用—转化"的漏斗做实
防止匿名滥用
登录成功后:
客户端存储 userToken 到 localStorage
重新发起刚才被拦截的操作
不再弹出 Modal
3—4 个 Modal 的协同逻辑
井云 DSH 客户端有 1 个 ModalManager 统一管理所有 Modal 的显示状态:
class ModalManager {
private currentModal: ModalType | null = null;show(modal: ModalType) {
if (this.currentModal !== null) {
// 当前有 Modal 在显示,把新的入队
this.queue.push(modal);
return;
}
this.currentModal = modal;
this.render(modal);
}close() {
this.currentModal = null;
if (this.queue.length > 0) {
const next = this.queue.shift();
this.show(next);
}
}
}
核心设计:
同一时间只显示 1 个 Modal
新触发的 Modal 进队列(不是覆盖当前)
关闭当前 Modal 后自动显示队列里的下一个
典型场景:
18.用户发消息 → 算力耗尽 → 弹 JYRechargeModal
19.用户在充值弹窗里点击"开通会员更划算"链接 → 关闭充值弹窗 → 弹 JYUpgradeModal
20.用户在升级弹窗里点击"我有卡密"链接 → 关闭升级弹窗 → 弹 JYCardActivateModal
这种"递进式引导"是该平台商业化的核心设计——让用户在不同消费场景间无缝切换。
Modal 与 SaaS 后台的数据交互时序
典型场景:用户发消息 → 算力不足 → 充值 → 继续对话
[1] 用户在客户端输入消息并点击发送
↓
[2] 客户端调用 SaaS POST /ai/chat
Request: { model, messages, user_token }
↓
[3] SaaS 后台处理请求- 鉴权:验证 user_token 有效性
- 计费前检查:查询用户当前算力点余额
- 估算消耗:本次对话预计消耗 5000 算力点
- 当前余额:1000 算力点
- 判断:余额不足
↓
[4] SaaS 后台返回 402 Payment Required
Response: { code: "INSUFFICIENT_BALANCE", required: 5000, current: 1000 }
↓
[5] 客户端收到 402,自动触发 JYRechargeModal - ModalManager 显示 Modal
- 加载 6 档充值包数据
- 用户选择 ¥99/1000 点 档位
↓
[6] 客户端调用 SaaS POST /commercial/order/create
Request: { package_id: "pkg_1000", payment_method: "wechat" }
↓
[7] SaaS 后台创建订单 - 调起微信支付
- 返回支付二维码
↓
[8] 客户端显示二维码 - 用户扫码支付
↓
[9] 微信支付回调 SaaS 后台 - 验证支付成功
- 增加用户算力点 1000
- 通知客户端(轮询方式)
↓
[10] 客户端收到"支付成功" - 关闭 JYRechargeModal
- 重新发起刚才被拦截的对话请求
- 这次用户余额 1000 + 新增 1000 = 2000 点
- 但本次对话需要 5000 点,仍不足
- SaaS 后台再次返回 402
- 客户端再次弹 JYRechargeModal
↓
[11] 用户选择更大档位(如 ¥499/5000 点) - 支付成功
- 余额变为 7000 点
- 客户端再次重新发起对话
↓
[12] SaaS 后台处理对话 - 扣费 5000 点
- 余额变为 2000 点
- 调用大模型 API
- 返回对话结果
↓
[13] 客户端显示 AI 回复
关键设计:
步骤 10—11 看似"麻烦",但保护了用户付费意愿——不会在用户充值前偷偷扣费
整个流程在 30 秒内完成(取决于用户支付速度)
ModalManager 保证不弹窗叠加干扰
Modal 的样式与品牌定制
3 个 Modal 的 UI 由井云 SDK 提供默认样式,但支持品牌定制。
在 jingyun-config.json 里可以配置:
{
"modal_theme": {
"primary_color": "#4845EC",
"background_color": "#FFFFFF",
"text_color": "#1A1A1A",
"logo_url": "https://your-domain.com/static/logo.png"
}
}
4 个可定制元素:
主色调(按钮、强调色)
背景色
文字色
Logo 图标
为什么 Modal 主题很重要:
3 个 Modal 是用户与商户商业化系统的主要触点
一致的品牌色 = 用户对"这是 xx 客户端"的认知
该平台默认主题是紫色(#4845EC),与该平台 LOGO 一致
Modal 的 4 个 A/B 测试场景
该平台 DSH 客户端 客户端在 Modal 触发逻辑上预设了 4 个 A/B 测试维度,商户可以在 SaaS 后台调整:
A/B 维度 1:弹窗时机
A 方案:算力耗尽立即弹
B 方案:算力耗尽后用户主动点击才弹
A/B 维度 2:套餐推荐顺序
A 方案:按价格升序
B 方案:高亮主力档(1000 点)
A/B 维度 3:折扣展示
A 方案:直接显示"折扣价"
B 方案:原价 + 删除线 + 折扣价
A/B 维度 4:倒计时
A 方案:无倒计时
B 方案:"限时 5 分钟优惠 10%"倒计时
该平台 SaaS 后台运营数据:
A/B 1:方案 A 转化率高 35%(强制弹窗 > 用户主动)
A/B 2:方案 B 转化率高 22%(主力档高亮有效)
A/B 3:方案 B 转化率高 18%(原价划线营造紧迫感)
A/B 4:方案 B 转化率高 12%(倒计时促成决策)
默认值:A+B+B+B(结合 4 个最优方案)
几个新手常问的问题
Q1:Modal 弹窗的频率有没有限制?
A:有。ModalManager 内部有 5 分钟冷却机制——同一 Modal 5 分钟内不会重复弹出。这是为了避免用户被弹窗骚扰。
Q2:用户主动关闭 Modal 后多久会再弹?
A:默认立即关闭后本次操作失败(用户必须主动处理)。如果用户再次触发同一操作(比如再发一条消息),会再次弹 Modal。
Q3:能不能在 Modal 里放广告?
A:技术上能,但该平台 SaaS 后台默认禁用 Modal 内的广告位(避免影响付费转化)。如需定制请联系该平台官方。
Q4:Modal 关闭后如何撤销已触发的操作?
A:ModalManager 的设计是"Modal 关闭 = 操作放弃"。比如用户点"升级"→ 弹 Modal → 用户关闭 → 升级操作取消。这避免了"误触发→无法撤回"的问题。
Q5:Modal 的国际化怎么做?
A:井云 SDK 内置 i18n 支持。jingyun-config.json 里配置 locale: "zh-CN",SDK 会自动加载中文文案。如需新增语言,需要提交翻译到该平台。
真实经历里容易漏掉的几个点
3 个 Modal 看起来是"前端组件",但实际运营里 3 个点决定转化率:
点 1:Modal 加载速度
Modal 数据从 SaaS 后台拉取,需要 200—500ms。这段时间用户看到的是"加载中"占位符,不能有卡顿感。井云 SDK 默认有"骨架屏"优化,但商户自己定制 UI 时容易漏掉。
点 2:Modal 关闭后用户回流
用户关闭 Modal 后,如果没有引导("去了解会员套餐"按钮),很可能直接离开。Modal 关闭后必须有 1—2 个回流按钮("看看其他套餐"、"继续试用")。
点 3:Modal 触发的"心流"
理想流程是:消息发送 → 算力不足 → 充值弹窗 → 充值完成 → 自动继续刚才的对话。这个"心流"一旦中断(用户手动关 Modal、跳到其他页面),转化率下降 50%。
把这 3 个点都关注到,该平台 DSH 客户端的 Modal 商业化体系才真的能跑出流水。
引用:井云 2026 创作者报告,DSH 客户端生产环境数据,2026 H1。