uni-app 实战:Android 双卡短信发送、模板管理与系统短信兜底

uni-app 实战:Android 双卡短信发送、模板管理与系统短信兜底(vivo/荣耀可以无痕发送,小米/oppo需要用户在手机上二次确认发送)

说明:本文基于真实项目实现整理,但所有接口、字段、号码、业务数据均已脱敏,仅保留可复用的实现思路与代码结构。

一、需求背景

在移动端应用中,短信功能通常不是一个"点击发送"这么简单的按钮,而是一套完整能力:

  1. 进入短信编辑界面或短信弹层。
  2. 支持加载常用短信模板。
  3. 支持新增、删除模板。
  4. 支持双卡设备选择卡 1 或卡 2 发送。
  5. 发送前自动校验短信权限。
  6. 如果原生短信发送失败,自动降级到系统短信界面。

这类功能非常适合采用 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 环境下可以通过:

  • TelephonyManager
  • SubscriptionManager

获取当前设备的活动卡槽列表,再进一步拿到:

  • 卡槽索引。
  • 运营商名称。
  • 订阅 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_SMS
  • READ_PHONE_STATE
  • READ_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. 为什么要判断长短信分段

短信内容过长时,单条发送可能失败或被截断,所以建议优先调用:

  • divideMessage
  • sendMultipartTextMessage

这样兼容性会更好。

十一、第七步:准备系统短信界面兜底

真实项目里,短信能力再完整,也要准备兜底方案。因为用户手机上可能出现:

  • 权限被永久拒绝。
  • 厂商系统限制原生短信发送。
  • 当前 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. 为什么要把 slotLabelsendPhoneNumber 一并保留

因为这两个字段在后续场景里都可能用到:

  • 页面提示“当前使用卡 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 双卡短信发送
  • 短信模板管理
  • 原生能力混合开发

这套方案非常适合直接复用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值