uni-app 实战:Android 双卡短信发送、模板管理与系统短信兜底(vivo/荣耀可以无痕发送,小米/oppo需要用户在手机上二次确认发送)
说明:本文基于真实项目实现整理,但所有接口、字段、号码、业务数据均已脱敏,仅保留可复用的实现思路与代码结构。
一、需求背景
在移动端应用中,短信功能通常不是一个"点击发送"这么简单的按钮,而是一套完整能力:
- 进入短信编辑界面或短信弹层。
- 支持加载常用短信模板。
- 支持新增、删除模板。
- 支持双卡设备选择卡 1 或卡 2 发送。
- 发送前自动校验短信权限。
- 如果原生短信发送失败,自动降级到系统短信界面。
这类功能非常适合采用 uni-app + Android 原生能力 的组合方案来实现。持双卡设备选择卡 1 或卡 2 发送。
5. 发送前自动校验短信权限。
6. 如果原生短信发送失败,自动降级到系统短信界面。
这类功能非常适合做成 uni-app + Android 原生能力 的组合方案。
二、最终效果
一个完整的短信功能建议包含两种入口:
- 页面式短信编辑器。
- 弹层式短信发送面板。
用户操作流程通常如下:
进入客户详情
->
点击发送短信
->
加载短信模板
->
编辑短信内容
->
选择卡1或卡2
->
校验短信权限
->
原生发送
->
失败时降级为系统短信界面
三、整体设计思路
建议把短信相关能力统一收口到一个工具模块中,例如 sms-utils.js,由它负责:
- 双卡信息识别。
- 短信模板数据处理。
- 权限申请。
- 指定 SIM 卡发送短信。
- 系统短信界面兜底。
页面层只做三件事:
- 收集用户信息。
- 收集短信内容。
- 调用统一的提交方法。## 四、短信模块应该拆成哪些能力
1. 模板能力
- 获取模板列表。
- 保存自定义模板。
- 删除模板。
- 对模板内容做客户名称替换。
2. 设备能力
- 获取双卡信息。
- 判断卡槽是否可用。
- 获取 SIM 卡对应号码。
- 根据卡槽找到
subscriptionId。
3. 发送能力
- 构造发送参数。
- 校验短信权限。
- 使用指定 SIM 卡发送。
- 降级打开系统短信界面。
五、第一步:统一模板数据结构
实际项目里,模板接口返回字段不一定稳定,所以建议先做一次标准化。
1. 标准化输出格式
[
{
id: "sms-template-1",
templateName: "邀约跟进模板",
smsContent: "您好,我是业务顾问,想和您确认下需求,方便时请回电。",
},
];
2. 标准化函数示例
const DEFAULT_SMS_TEMPLATES = [
{
id: "sms-template-1",
templateName: "邀约跟进模板",
smsContent: "您好,我是业务顾问,想和您确认下需求,方便时请回电。",
},
{
id: "sms-template-2",
templateName: "资料补充模板",
smsContent: "您好,当前申请资料还差部分内容,请看到短信后及时联系我。",
},
];
function normalizeSmsTemplates(templateList = [], customerName = "") {
const safeCustomerName = customerName || "客户";
const normalizedList = templateList
.filter((item) => item && item.smsContent)
.map((item, index) => ({
...item,
id: item.id || `sms-template-${index + 1}`,
templateName: item.templateName || item.name || `短信模板${index + 1}`,
smsContent: String(item.smsContent || "").replace(/客户/g, safeCustomerName),
}));
return normalizedList.length
? normalizedList
: DEFAULT_SMS_TEMPLATES.map((item) => ({
...item,
smsContent: item.smsContent.replace(/客户/g, safeCustomerName),
}));
}
3. 为什么模板内容里要替换客户名称
这是个非常实用的小细节。比如模板写的是“客户您好”,实际使用时替换成具体客户称呼,体验会更自然,也能减少人工编辑成本。
六、第二步:封装模板列表、保存、删除接口
文章发布时不要放真实接口,这里统一用占位接口名表示。## 七、第三步:识别 Android 双卡信息
如果业务员用的是双卡机,短信一定不能只支持默认卡,否则实际使用会很痛苦。
1. 推荐返回结构
[
{
key: "card-1",
label: "卡1发送(13800000000)",
slotIndex: 0,
slotLabel: "卡1",
phoneNumber: "13800000000",
available: true,
disabled: false,
},
{
key: "card-2",
label: "卡2未插卡",
slotIndex: 1,
slotLabel: "卡2",
phoneNumber: "",
available: false,
disabled: true,
},
];
2. 构造卡槽对象
function buildSmsCardOption(card = {}, slotIndex = 0) {
const resolvedSlotIndex = Number.isFinite(Number(card.slotIndex))
? Number(card.slotIndex)
: Number(slotIndex);
const slotNumber = resolvedSlotIndex + 1;
const slotLabel = card.slotLabel || `卡${slotNumber}`;
const phoneNumber = normalizePhoneNumber(card.phoneNumber);
const available = card.available !== false;
let label = `${slotLabel}发送`;
if (available) {
label = phoneNumber ? `${slotLabel}发送(${phoneNumber})` : `${slotLabel}发送`;
} else {
label = `${slotLabel}未插卡`;
}
return {
key: card.key || `card-${slotNumber}`,
label,
slotIndex: resolvedSlotIndex,
slotLabel,
phoneNumber,
available,
disabled: card.disabled === true || !available,
};
}
3. Android 双卡识别思路
在 Android 环境下可以通过:
TelephonyManagerSubscriptionManager
获取当前设备的活动卡槽列表,再进一步拿到:
- 卡槽索引。
- 运营商名称。
- 订阅 ID。
- 本机号码。
4. 获取卡信息示例
function getAndroidSmsCardOptions() {
const cardMap = createDefaultSmsCardOptions();
const mainActivity = plus.android.runtimeMainActivity();
const SubscriptionManager = plus.android.importClass("android.telephony.SubscriptionManager");
const subscriptionManager = SubscriptionManager.from(mainActivity);
const activeList = subscriptionManager.getActiveSubscriptionInfoList();
if (activeList && activeList.size && activeList.size() > 0) {
for (let index = 0; index < activeList.size(); index += 1) {
const info = activeList.get(index);
plus.android.importClass(info);
const slotIndex = Number(info.getSimSlotIndex ? info.getSimSlotIndex() : index);
const subscriptionId = Number(info.getSubscriptionId ? info.getSubscriptionId() : -1);
const carrierName = info.getCarrierName ? String(info.getCarrierName() || "") : "";
cardMap[slotIndex] = buildSmsCardOption({
slotIndex,
slotLabel: `卡${slotIndex + 1}`,
carrierName,
phoneNumber: getPhoneNumberBySubscription(mainActivity, subscriptionManager, subscriptionId, info),
available: true,
disabled: false,
});
}
}
return cardMap;
}
5. 为什么要兜底默认卡槽
部分设备虽然能识别出卡槽,但拿不到手机号。这种情况下也不要直接判失败,而是至少保留:
- 卡 1
- 卡 2
- 是否可用
这样用户至少还能继续发送。
八、第四步:发送前校验短信权限
Android 原生发短信不是无权限能力,至少要在发送前统一做权限校验。
1. 常见需要的权限
SEND_SMSREAD_PHONE_STATEREAD_PHONE_NUMBERS
2. 推荐的权限申请方法
async function ensureNativeSmsPermissions() {
const permissionIds = [
"android.permission.SEND_SMS",
"android.permission.READ_PHONE_STATE",
"android.permission.READ_PHONE_NUMBERS",
];
for (let index = 0; index < permissionIds.length; index += 1) {
const permissionId = permissionIds[index];
const result = await requestAndroidPermission(permissionId);
if (result === 1) {
continue;
}
if (result === -1) {
return {
success: false,
message: "请在系统设置中开启短信和电话权限",
permanentDenied: true,
};
}
return {
success: false,
message: "未获得短信发送权限",
};
}
return {
success: true,
message: "",
};
}
3. 为什么不仅要 SEND_SMS
因为你还需要:
- 识别当前可用卡槽。
- 获取指定卡槽的订阅信息。
- 找到对应 SIM 卡。
这些过程经常依赖电话相关权限。
九、第五步:根据卡槽找到 subscriptionId
指定 SIM 卡发送短信时,核心不是“卡槽号”,而是 subscriptionId。
1. 解析方法
function resolveSubscriptionIdBySlot(mainActivity, slotIndex) {
const SubscriptionManager = plus.android.importClass("android.telephony.SubscriptionManager");
const subscriptionManager = SubscriptionManager.from(mainActivity);
if (subscriptionManager && subscriptionManager.getActiveSubscriptionInfoForSimSlotIndex) {
const matchedInfo = subscriptionManager.getActiveSubscriptionInfoForSimSlotIndex(Number(slotIndex));
if (matchedInfo) {
plus.android.importClass(matchedInfo);
return Number(matchedInfo.getSubscriptionId ? matchedInfo.getSubscriptionId() : -1);
}
}
return -1;
}
2. 为什么这里不能只传 slotIndex
因为 Android 原生 SmsManager 在很多系统版本下真正使用的是:
subscriptionId
而不是直接用:
slotIndex
这点很关键,很多“明明选择了卡 2,结果还是卡 1 发送”的问题,根源就在这里。
十、第六步:使用指定 SIM 卡发送短信
1. 原生发送逻辑
function sendNativeSmsByCard({ mobile, smsContent, slotIndex }) {
const mainActivity = plus.android.runtimeMainActivity();
const SmsManager = plus.android.importClass("android.telephony.SmsManager");
const subscriptionId = resolveSubscriptionIdBySlot(mainActivity, slotIndex);
if (slotIndex != null && subscriptionId < 0) {
throw new Error(`卡${Number(slotIndex) + 1}未插卡,无法发送`);
}
let smsManager = null;
if (subscriptionId >= 0 && SmsManager.getSmsManagerForSubscriptionId) {
smsManager = SmsManager.getSmsManagerForSubscriptionId(subscriptionId);
} else if (SmsManager.getDefault) {
smsManager = SmsManager.getDefault();
}
if (!smsManager) {
throw new Error("未获取到短信管理器");
}
plus.android.importClass(smsManager);
const safePhoneNumber = normalizePhoneNumber(mobile);
const safeSmsContent = String(smsContent || "").trim();
const messageParts = smsManager.divideMessage
? smsManager.divideMessage(safeSmsContent)
: null;
if (messageParts && messageParts.size && messageParts.size() > 1 && smsManager.sendMultipartTextMessage) {
smsManager.sendMultipartTextMessage(safePhoneNumber, null, messageParts, null, null);
} else {
smsManager.sendTextMessage(safePhoneNumber, null, safeSmsContent, null, null);
}
return {
success: true,
message: "发送成功",
};
}
2. 为什么要判断长短信分段
短信内容过长时,单条发送可能失败或被截断,所以建议优先调用:
divideMessagesendMultipartTextMessage
这样兼容性会更好。
十一、第七步:准备系统短信界面兜底
真实项目里,短信能力再完整,也要准备兜底方案。因为用户手机上可能出现:
- 权限被永久拒绝。
- 厂商系统限制原生短信发送。
- 当前 ROM 不支持指定卡发送。
- 原生 API 调用异常。
这时最稳妥的处理就是打开系统短信界面,让用户手动确认发送。
1. Android 打开系统短信界面
function openSystemSmsComposer({ mobile, smsContent }) {
const safePhoneNumber = normalizePhoneNumber(mobile);
const safeSmsContent = String(smsContent || "").trim();
const mainActivity = plus.android.runtimeMainActivity();
const Intent = plus.android.importClass("android.content.Intent");
const Uri = plus.android.importClass("android.net.Uri");
const intent = new Intent(Intent.ACTION_SENDTO, Uri.parse(`smsto:${safePhoneNumber}`));
intent.putExtra("sms_body", safeSmsContent);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
mainActivity.startActivity(intent);
return {
success: true,
message: "已打开系统短信界面,请手动确认发送",
fallbackToSystemComposer: true,
};
}
2. 为什么兜底非常重要
对业务来说,最关键的是“消息发出去”,而不是“必须通过 App 静默发出去”。
只要能在失败时自动拉起系统短信编辑界面,用户通常都能接受,功能可用性会高很多。
十二、第八步:统一封装短信提交入口
页面层最好只调一个统一方法,比如 submitSms(),里面把所有流程串起来。## 十三、第九步:构造页面层发送参数
页面层不应该直接拼装一堆原生参数,建议先构造成统一业务载荷。
1. 统一载荷结构
function buildSmsPayload({ customerId, mobile, smsContent, cardOption }) {
const card = cardOption || {};
return {
customerId: String(customerId || ""),
mobile: mobile || "",
smsContent: String(smsContent || "").trim(),
type: 0,
slotIndex: card.slotIndex != null ? Number(card.slotIndex) : "",
slotLabel: card.slotLabel || "",
cardAvailable: card.available !== false,
sendPhoneNumber: card.phoneNumber || "",
};
}
2. 为什么要把 slotLabel 和 sendPhoneNumber 一并保留
因为这两个字段在后续场景里都可能用到:
- 页面提示“当前使用卡 1 发送”。
- 日志追踪。
- 后续埋点统计。
十四、第十步:页面式短信编辑器实现
如果你的业务是一个单独页面来写短信,可以做成这种结构:
1. 页面职责
- 展示客户姓名和手机号。
- 提供短信输入框。
- 底部展示卡 1、卡 2 发送按钮。
2. 示例代码
async function handleSend(cardItem) {
if (!cardItem || cardItem.disabled) {
uni.showToast({
title: (cardItem && cardItem.label) || "当前卡槽不可发送",
icon: "none",
});
return;
}
const payload = buildSmsPayload({
customerId: this.customerId,
customerName: this.customerName,
mobile: this.mobile,
smsContent: this.content,
cardOption: cardItem,
});
if (!payload.smsContent) {
uni.showToast({
title: "请输入短信内容",
icon: "none",
});
return;
}
if (!payload.mobile) {
uni.showToast({
title: "缺少客户手机号",
icon: "none",
});
return;
}
uni.showLoading({ title: "发送中..." });
const result = await submitCrmSms(this.$api, payload);
uni.hideLoading();
uni.showToast({
title: result.message || (result.success ? "发送成功" : "发送失败"),
icon: "none",
});
if (result.success) {
setTimeout(() => {
uni.navigateBack();
}, 300);
}
}
十五、第十一步:弹层式短信组件实现
在客户详情里,短信功能常常更适合做成底部弹层。
1. 弹层通常包含这些区域
- 顶部标题。
- 短信输入框。
- 模板列表。
- 保存模板按钮。
- 发送按钮。
- 发送卡选择层。
2. 弹层打开时做什么
建议在弹层打开时同步完成:
- 刷新卡槽信息。
- 拉取模板列表。
- 清空草稿内容。
async function handleOpen() {
this.cardPickerVisible = false;
this.cardOptions = getCrmSmsCardOptions();
this.draftContent = "";
this.localTemplates = await fetchSmsTemplates(this.$api, this.customerName);
}
3. 发送按钮不要直接发送
推荐先点“发送”,再弹出“选择发送卡号”的二级面板,这样体验更清晰:
handleSend() {
const content = String(this.draftContent || "").trim();
if (!content) {
uni.showToast({
title: "请输入短信内容",
icon: "none",
});
return;
}
this.cardPickerVisible = true;
}
4. 选择卡后再真正发送
async function handleCardSend(cardItem) {
const payload = buildSmsPayload({
customerId: this.customerId,
mobile: this.mobile,
smsContent: this.draftContent,
cardOption: cardItem,
});
uni.showLoading({ title: "发送中..." });
const result = await submitCrmSms(this.$api, payload);
uni.hideLoading();
uni.showToast({
title: result.message || (result.success ? "发送成功" : "发送失败"),
icon: "none",
});
if (result.success) {
this.close();
}
}
十六、完整伪代码串起来看
async function sendSmsWorkflow({ customerId, customerName, mobile, content }) {
const templates = await fetchSmsTemplates(api, customerName);
const cardOptions = getCrmSmsCardOptions();
const selectedCard = await chooseSmsCard(cardOptions);
if (!selectedCard) {
return;
}
const payload = buildSmsPayload({
customerId,
mobile,
smsContent: content,
cardOption: selectedCard,
});
if (!payload.mobile || !payload.smsContent) {
showErrorToast("手机号或短信内容为空");
return;
}
const result = await submitCrmSms(api, payload);
if (result.success) {
showSuccessToast(result.message || "发送成功");
return;
}
showErrorToast(result.message || "发送失败");
}
十七、最容易踩的坑
1. 默认短信管理器不一定支持指定卡发送
不是所有机型都能通过同一套 API 完成双卡指定发送,所以一定要准备系统短信界面兜底。
2. 卡槽号和订阅 ID 不是一回事
很多双卡发送异常,本质上是没有正确把 slotIndex 转成 subscriptionId。
3. 只申请 SEND_SMS 权限
如果没有电话相关权限,很多时候拿不到卡槽和号码信息。
4. 长短信没有做分段
容易导致内容被截断,或者发送失败。
5. 权限被永久拒绝后没有兜底
最好的处理方式不是直接报错,而是:
- 提示用户去系统设置开启权限。
- 同时允许打开系统短信界面手动发送。
十八、推荐的模块拆分
1. crm-sms.js
负责:
- 模板标准化
- 模板接口
- 双卡识别
- 权限校验
- 短信发送
- 系统短信兜底
2. sms-editor.vue
负责:
- 页面输入
- 调用统一发送入口
3. sms-popup.vue
负责:
- 模板展示
- 模板保存删除
- 卡槽选择
- 弹层交互
十九、总结
要把短信功能真正做成可落地的业务能力,核心不是“调一下发短信 API”,而是把下面几件事都处理完整:
- 模板可维护。
- 双卡可识别。
- 卡槽可选择。
- 权限可申请。
- 指定卡可发送。
- 失败可兜底。
如果你做的是:
uni-app客户跟进系统- Android 双卡短信发送
- 短信模板管理
- 原生能力混合开发
这套方案非常适合直接复用。

133

被折叠的 条评论
为什么被折叠?



