补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
542 行
13 KiB
Plaintext
542 行
13 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 名片主 NFC 贴卡激活业务驱动层
|
||
* H5 无天线走手动 UID;APP 经 uni.getNFCAdapter 尝试读芯片 UID,失败回退手动输入
|
||
*/
|
||
|
||
import { NFCRecord } from '../../../common/types.uts';
|
||
import { bindNfcDevice, activateNfcDevice } from '../../../common/api.uts';
|
||
import { getCurrentLocale, getOmniText } from '../../../common/locale.uts';
|
||
|
||
export type ScanCopyKey =
|
||
| 'title'
|
||
| 'desc'
|
||
| 'uidLabel'
|
||
| 'uidPlaceholder'
|
||
| 'cardIdLabel'
|
||
| 'cardIdPlaceholder'
|
||
| 'cta'
|
||
| 'empty'
|
||
| 'uidRequired'
|
||
| 'cardIdRequired'
|
||
| 'nfcUnavailable'
|
||
| 'success'
|
||
| 'error'
|
||
| 'retry'
|
||
| 'writeSuccess'
|
||
| 'writeFailed'
|
||
| 'writeUnavailable'
|
||
| 'writeCancelled';
|
||
|
||
export type ScanState = {
|
||
uid: string;
|
||
cardIdRaw: string;
|
||
deviceCardId: number | null;
|
||
submitting: boolean;
|
||
scanning: boolean;
|
||
manualInput: boolean;
|
||
errorMessage: string;
|
||
empty: boolean;
|
||
record: NFCRecord | null;
|
||
};
|
||
|
||
export type ScanPageOptions = {
|
||
cardId?: string;
|
||
uid?: string;
|
||
id?: string;
|
||
};
|
||
|
||
export type ActivateByTapInput = {
|
||
uid: string;
|
||
cardId?: string | number | null;
|
||
deviceCardId?: string | number | null;
|
||
};
|
||
|
||
function isZhLocale(): boolean {
|
||
return getCurrentLocale() === 'zh-CN';
|
||
}
|
||
|
||
/**
|
||
* 页面文案:能复用 locale.uts 的走词典,缺 key 时中英本地词典
|
||
*/
|
||
export function resolveScanText(key: ScanCopyKey): string {
|
||
const locale = getCurrentLocale();
|
||
if (key === 'title') {
|
||
return getOmniText(locale, 'card.scan_nfc');
|
||
}
|
||
if (key === 'success') {
|
||
return getOmniText(locale, 'common.success');
|
||
}
|
||
if (key === 'error') {
|
||
return getOmniText(locale, 'common.error');
|
||
}
|
||
if (key === 'empty') {
|
||
return getOmniText(locale, 'common.empty');
|
||
}
|
||
|
||
const zh = isZhLocale();
|
||
if (key === 'desc') {
|
||
return zh
|
||
? '贴近实体卡,或手动填写芯片 UID 完成绑定'
|
||
: 'Hold the card to this phone, or enter the chip UID to bind it';
|
||
}
|
||
if (key === 'uidLabel') {
|
||
return zh ? '芯片 UID' : 'Chip UID';
|
||
}
|
||
if (key === 'uidPlaceholder') {
|
||
return zh ? '输入芯片 UID' : 'Enter chip UID';
|
||
}
|
||
if (key === 'cardIdLabel') {
|
||
return zh ? '名片编号' : 'Card ID';
|
||
}
|
||
if (key === 'cardIdPlaceholder') {
|
||
return zh ? '输入名片编号' : 'Enter card ID';
|
||
}
|
||
if (key === 'cta') {
|
||
return zh ? '激活并绑定' : 'Activate and bind';
|
||
}
|
||
if (key === 'uidRequired') {
|
||
return zh ? '请填写芯片 UID' : 'Enter the chip UID';
|
||
}
|
||
if (key === 'cardIdRequired') {
|
||
return zh ? '请填写名片编号' : 'Enter a card ID';
|
||
}
|
||
if (key === 'nfcUnavailable') {
|
||
return zh
|
||
? '当前无法读取 NFC,请手动填写 UID'
|
||
: 'NFC is unavailable. Enter the UID instead';
|
||
}
|
||
if (key === 'retry') {
|
||
return zh ? '重试' : 'Try again';
|
||
}
|
||
if (key === 'writeSuccess') {
|
||
return zh ? '已写入' : 'Written to tag';
|
||
}
|
||
if (key === 'writeFailed') {
|
||
return zh ? '写入失败' : 'Could not write';
|
||
}
|
||
if (key === 'writeUnavailable') {
|
||
return zh ? '暂无可写内容' : 'Nothing to write';
|
||
}
|
||
if (key === 'writeCancelled') {
|
||
return zh ? '已取消写入' : 'Write cancelled';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 去空。空串视为非法 UID
|
||
*/
|
||
export function parseUid(raw: string | number | null | undefined): string {
|
||
if (raw == null) {
|
||
return '';
|
||
}
|
||
return String(raw).trim();
|
||
}
|
||
|
||
/**
|
||
* 解析为正整数。非正整数返回 null
|
||
*/
|
||
export function parseCardId(raw: string | number | null | undefined): number | null {
|
||
if (raw == null) {
|
||
return null;
|
||
}
|
||
if (typeof raw === 'number') {
|
||
if (raw <= 0) {
|
||
return null;
|
||
}
|
||
if (Math.floor(raw) !== raw) {
|
||
return null;
|
||
}
|
||
return raw;
|
||
}
|
||
const text = String(raw).trim();
|
||
if (text === '') {
|
||
return null;
|
||
}
|
||
if (!/^[1-9]\d*$/.test(text)) {
|
||
return null;
|
||
}
|
||
return Number(text);
|
||
}
|
||
|
||
function readErrorMessage(err: unknown): string {
|
||
if (err instanceof Error && err.message !== '') {
|
||
return err.message;
|
||
}
|
||
return resolveScanText('error');
|
||
}
|
||
|
||
/**
|
||
* 贴卡激活:有入参 cardId 则 bind;设备已有 cardId 且入参为空则 activate
|
||
*/
|
||
export async function activateByTap(
|
||
input: ActivateByTapInput,
|
||
existing?: { cardId?: string | number | null } | null
|
||
): Promise<NFCRecord> {
|
||
const uid = parseUid(input.uid);
|
||
if (uid === '') {
|
||
throw new Error(resolveScanText('uidRequired'));
|
||
}
|
||
|
||
const incomingCardId = parseCardId(input.cardId);
|
||
const fromInputDevice = parseCardId(input.deviceCardId);
|
||
const fromExisting = existing != null ? parseCardId(existing.cardId) : null;
|
||
const deviceCardId = fromInputDevice != null ? fromInputDevice : fromExisting;
|
||
|
||
try {
|
||
if (incomingCardId == null && deviceCardId != null) {
|
||
return await activateNfcDevice({ uid });
|
||
}
|
||
if (incomingCardId != null) {
|
||
return await bindNfcDevice({ uid, cardId: incomingCardId });
|
||
}
|
||
throw new Error(resolveScanText('cardIdRequired'));
|
||
} catch (err) {
|
||
throw new Error(readErrorMessage(err));
|
||
}
|
||
}
|
||
|
||
export function createScanState(): ScanState {
|
||
return {
|
||
uid: '',
|
||
cardIdRaw: '',
|
||
deviceCardId: null,
|
||
submitting: false,
|
||
scanning: false,
|
||
manualInput: true,
|
||
errorMessage: '',
|
||
empty: true,
|
||
record: null,
|
||
};
|
||
}
|
||
|
||
export function applyScanOptions(state: ScanState, options: ScanPageOptions | null): void {
|
||
if (options == null) {
|
||
return;
|
||
}
|
||
if (options.uid != null) {
|
||
state.uid = parseUid(options.uid);
|
||
}
|
||
const cardRaw =
|
||
options.cardId != null && options.cardId !== ''
|
||
? options.cardId
|
||
: options.id != null
|
||
? options.id
|
||
: '';
|
||
if (cardRaw !== '') {
|
||
state.cardIdRaw = String(cardRaw).trim();
|
||
}
|
||
state.empty = parseUid(state.uid) === '';
|
||
}
|
||
|
||
/**
|
||
* NFC 标签 id(ArrayBuffer)转大写 Hex UID;空则返回空串
|
||
*/
|
||
function nfcIdToHex(id: ArrayBuffer | null | undefined): string {
|
||
if (id == null) {
|
||
return '';
|
||
}
|
||
const bytes = new Uint8Array(id);
|
||
let hex = '';
|
||
let i = 0;
|
||
const len = bytes.length;
|
||
while (i < len) {
|
||
let part = bytes[i].toString(16);
|
||
if (part.length < 2) {
|
||
part = '0' + part;
|
||
}
|
||
hex += part;
|
||
i++;
|
||
}
|
||
return hex.toUpperCase();
|
||
}
|
||
|
||
type NfcFail = {
|
||
errMsg?: string;
|
||
};
|
||
|
||
type NfcIdResult = {
|
||
id?: ArrayBuffer | null;
|
||
};
|
||
|
||
type NfcCallbackOptions = {
|
||
success?: () => void;
|
||
fail?: (err: NfcFail) => void;
|
||
};
|
||
|
||
type NdefWriteOptions = {
|
||
uris?: string[];
|
||
success?: () => void;
|
||
fail?: (err: NfcFail) => void;
|
||
};
|
||
|
||
type NdefWriter = {
|
||
writeNdefMessage: (options: NdefWriteOptions) => void;
|
||
connect?: (options: NfcCallbackOptions) => void;
|
||
};
|
||
|
||
type NfcAdapter = {
|
||
getNdef: () => NdefWriter | null;
|
||
startDiscovery: (options: NfcCallbackOptions) => void;
|
||
onDiscovered: (callback: (res: NfcIdResult) => void) => void;
|
||
};
|
||
|
||
type UniWithNfc = {
|
||
getNFCAdapter?: () => NfcAdapter | null;
|
||
};
|
||
|
||
function readNfcAdapter(): NfcAdapter | null {
|
||
const runtime = uni as UniWithNfc;
|
||
if (runtime.getNFCAdapter == null) {
|
||
return null;
|
||
}
|
||
return runtime.getNFCAdapter();
|
||
}
|
||
|
||
/**
|
||
* APP:调用官方风格 uni.getNFCAdapter + startDiscovery / onDiscovered 尝试读 UID。
|
||
* 同步路径仅在已读到合法 UID 时返回非空;读不到、API 不存在或抛错返回空串,禁止伪造。
|
||
*/
|
||
function tryReadUidFromAntenna(state: ScanState): string {
|
||
// #ifdef APP
|
||
try {
|
||
const adapter = readNfcAdapter();
|
||
if (adapter == null) {
|
||
return '';
|
||
}
|
||
let syncUid = '';
|
||
adapter.startDiscovery({
|
||
success: () => {
|
||
adapter.onDiscovered((res: NfcIdResult) => {
|
||
const uid = parseUid(nfcIdToHex(res.id));
|
||
if (uid === '') {
|
||
return;
|
||
}
|
||
// 异步贴卡成功:写入真实 UID,不伪造
|
||
state.uid = uid;
|
||
state.empty = false;
|
||
state.scanning = false;
|
||
state.manualInput = false;
|
||
state.errorMessage = '';
|
||
syncUid = uid;
|
||
});
|
||
},
|
||
fail: (_err: { errMsg?: string }) => {
|
||
// 由 startScan 统一走手动输入回退
|
||
},
|
||
});
|
||
return parseUid(syncUid);
|
||
} catch (_err) {
|
||
return '';
|
||
}
|
||
// #endif
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* APP 尝试官方 NFC Adapter 读 UID;仅合法 UID 才成功。否则回退手动输入,禁止伪造成功
|
||
*/
|
||
export function startScan(state: ScanState): boolean {
|
||
// #ifdef APP
|
||
try {
|
||
const uid = tryReadUidFromAntenna(state);
|
||
if (parseUid(uid) !== '') {
|
||
state.uid = parseUid(uid);
|
||
state.scanning = false;
|
||
state.manualInput = false;
|
||
state.empty = false;
|
||
state.errorMessage = '';
|
||
state.record = null;
|
||
return true;
|
||
}
|
||
} catch (_err) {
|
||
// 落入下方失败回退
|
||
}
|
||
// #endif
|
||
|
||
state.scanning = false;
|
||
state.manualInput = true;
|
||
state.record = null;
|
||
if (parseUid(state.uid) === '') {
|
||
state.empty = true;
|
||
state.errorMessage = resolveScanText('nfcUnavailable');
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function resolveStatusLine(state: ScanState): string {
|
||
if (state.errorMessage !== '') {
|
||
return state.errorMessage;
|
||
}
|
||
if (state.empty && parseUid(state.uid) === '') {
|
||
return resolveScanText('empty');
|
||
}
|
||
return '';
|
||
}
|
||
|
||
/**
|
||
* 页面主 CTA:校验后走 activateByTap,失败写入一句错误
|
||
*/
|
||
export async function submitScan(state: ScanState): Promise<NFCRecord | null> {
|
||
if (state.submitting) {
|
||
return null;
|
||
}
|
||
|
||
const uid = parseUid(state.uid);
|
||
if (uid === '') {
|
||
state.empty = true;
|
||
state.errorMessage = resolveScanText('uidRequired');
|
||
return null;
|
||
}
|
||
|
||
state.uid = uid;
|
||
state.empty = false;
|
||
state.submitting = true;
|
||
state.errorMessage = '';
|
||
|
||
try {
|
||
const record = await activateByTap(
|
||
{
|
||
uid,
|
||
cardId: state.cardIdRaw,
|
||
deviceCardId: state.deviceCardId,
|
||
},
|
||
{ cardId: state.deviceCardId }
|
||
);
|
||
state.submitting = false;
|
||
state.record = record;
|
||
if (record.cardId != null) {
|
||
const bound = parseCardId(record.cardId);
|
||
if (bound != null) {
|
||
state.deviceCardId = bound;
|
||
}
|
||
}
|
||
return record;
|
||
} catch (err) {
|
||
state.submitting = false;
|
||
state.record = null;
|
||
state.errorMessage = readErrorMessage(err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 可写 NDEF URI:有合法 cardId 才生成;否则空串表示无可写内容
|
||
*/
|
||
export function buildNdefUri(cardId: string | number | null | undefined): string {
|
||
const id = parseCardId(cardId);
|
||
if (id == null) {
|
||
return '';
|
||
}
|
||
return `https://omnicard.ai/c/${id}`;
|
||
}
|
||
|
||
function toastWriteUnavailable(): void {
|
||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||
return;
|
||
}
|
||
uni.showToast({
|
||
title: resolveScanText('writeUnavailable'),
|
||
icon: 'none',
|
||
});
|
||
}
|
||
|
||
function toastWriteFailed(): void {
|
||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||
return;
|
||
}
|
||
uni.showToast({
|
||
title: resolveScanText('writeFailed'),
|
||
icon: 'none',
|
||
});
|
||
}
|
||
|
||
function toastWriteCancelled(): void {
|
||
if (typeof uni === 'undefined' || typeof uni.showToast !== 'function') {
|
||
return;
|
||
}
|
||
uni.showToast({
|
||
title: resolveScanText('writeCancelled'),
|
||
icon: 'none',
|
||
});
|
||
}
|
||
|
||
function isWriteCancelledError(err: { errMsg?: string } | null | undefined): boolean {
|
||
if (err == null || err.errMsg == null) {
|
||
return false;
|
||
}
|
||
const msg = String(err.errMsg).toLowerCase();
|
||
return msg.indexOf('cancel') >= 0;
|
||
}
|
||
|
||
/**
|
||
* 贴卡写入 NDEF:仅系统写入接口的 success 回调才 toast 已写入。
|
||
* 天线未写 / 取消 / 失败 / 无可写内容均不得报写入成功。
|
||
*/
|
||
export function writeNdefToTag(uri: string | null | undefined): Promise<boolean> {
|
||
const payload = uri == null ? '' : String(uri).trim();
|
||
if (payload === '') {
|
||
toastWriteUnavailable();
|
||
return Promise.resolve(false);
|
||
}
|
||
|
||
return new Promise<boolean>(resolve => {
|
||
try {
|
||
const adapter = readNfcAdapter();
|
||
if (adapter == null) {
|
||
toastWriteFailed();
|
||
resolve(false);
|
||
return;
|
||
}
|
||
const ndef = adapter.getNdef();
|
||
if (ndef == null) {
|
||
toastWriteFailed();
|
||
resolve(false);
|
||
return;
|
||
}
|
||
|
||
const runWrite = (): void => {
|
||
ndef.writeNdefMessage({
|
||
uris: [payload],
|
||
success: () => {
|
||
uni.showToast({
|
||
title: resolveScanText('writeSuccess'),
|
||
icon: 'none',
|
||
});
|
||
resolve(true);
|
||
},
|
||
fail: (err: { errMsg?: string }) => {
|
||
if (isWriteCancelledError(err)) {
|
||
toastWriteCancelled();
|
||
} else {
|
||
toastWriteFailed();
|
||
}
|
||
resolve(false);
|
||
},
|
||
});
|
||
};
|
||
|
||
if (typeof ndef.connect === 'function') {
|
||
ndef.connect({
|
||
success: () => {
|
||
runWrite();
|
||
},
|
||
fail: (err: { errMsg?: string }) => {
|
||
if (isWriteCancelledError(err)) {
|
||
toastWriteCancelled();
|
||
} else {
|
||
toastWriteFailed();
|
||
}
|
||
resolve(false);
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
runWrite();
|
||
} catch (_err) {
|
||
toastWriteFailed();
|
||
resolve(false);
|
||
}
|
||
});
|
||
}
|