/** * OmniCard AI (CRMmp) - 名片主 NFC 场景模式切换业务驱动层 * 日间展会 business 与晚宴社交 social;挂失设备拒绝切换 */ import { NFCMaterial, NFCRecord, NFCSceneMode, NFCStatus, NfcBindParams, } from '../../../common/types.uts'; import { switchNfcCard, updateNfcDevice } from '../../../common/api.uts'; import { getCurrentLocale, getOmniText, OmniLocale, } from '../../../common/locale.uts'; export type ModeCopyKey = | 'title' | 'desc' | 'uidLabel' | 'cardIdLabel' | 'business' | 'social' | 'apply' | 'statusLabel' | 'sceneLabel' | 'statusActive' | 'statusSuspended' | 'statusUnbound' | 'suspendedError' | 'invalidMode' | 'missingUid' | 'missingCardId' | 'missingDevice' | 'applySuccess' | 'applyError' | 'loading' | 'offlineUnavailable'; export type ModePageOptions = { uid?: string; cardId?: string; id?: string; status?: string; sceneMode?: string; tenantId?: string; batchNo?: string; material?: string; }; export type NfcModeState = { uid: string; cardId: string; selectedMode: NFCSceneMode; status: string; sceneMode: string; device: NFCRecord | null; submitting: boolean; errorMessage: string; }; export type ApplySceneParams = { uid: string; cardId: string | number; sceneMode: string; device: NFCRecord | null; }; export type ApplySceneApi = { switchNfcCard: (params: NfcBindParams) => Promise; updateNfcDevice: ( payload: NFCRecord & { id: string | number } ) => Promise; }; export type ApplySceneResult = { ok: boolean; error: string; device: NFCRecord | null; }; export type OfflineWriteParams = { uid?: string; cardId?: string | number; vcard?: string; }; export type OfflineWriteResult = { ok: boolean; error: string; }; const MODE_COPY: Record> = { 'zh-CN': { title: '场景模式', desc: '支持商务与社交场景快速切换,挂失状态下不可切换。', uidLabel: '芯片 UID', cardIdLabel: '名片 ID', business: '日间展会', social: '晚宴社交', apply: '应用场景', statusLabel: '当前状态', sceneLabel: '当前场景', statusActive: '已激活', statusSuspended: '已挂失', statusUnbound: '未绑定', suspendedError: '设备已挂失,无法切换场景', invalidMode: '仅支持展会或社交场景', missingUid: '请填写芯片 UID', missingCardId: '请填写名片 ID', missingDevice: '缺少设备标识,无法更新场景', applySuccess: '操作成功', applyError: '操作失败', loading: '加载中...', offlineUnavailable: '此设备暂不能写入离线名片', }, 'en-US': { title: 'Scene mode', desc: 'Use Business for daytime expos and Social for evening events. Suspended cards cannot be switched.', uidLabel: 'Chip UID', cardIdLabel: 'Card ID', business: 'Daytime expo', social: 'Evening social', apply: 'Apply scene', statusLabel: 'Current status', sceneLabel: 'Current scene', statusActive: 'Active', statusSuspended: 'Suspended', statusUnbound: 'Unbound', suspendedError: 'This card is suspended and cannot change scenes', invalidMode: 'Only business or social scenes are available', missingUid: 'Enter the chip UID', missingCardId: 'Enter the card ID', missingDevice: 'A device ID is required to update the scene', applySuccess: 'Done', applyError: 'Something went wrong', loading: 'Loading...', offlineUnavailable: 'This device cannot write offline cards yet', }, 'ar-SA': { title: 'وضع المشهد', desc: 'استخدم وضع الأعمال للمعارض النهارية ووضع التواصل للأمسيات. لا يمكن تبديل البطاقة الموقوفة.', uidLabel: 'معرّف الشريحة', cardIdLabel: 'معرّف البطاقة', business: 'معرض نهاري', social: 'أمسية اجتماعية', apply: 'تطبيق المشهد', statusLabel: 'الحالة الحالية', sceneLabel: 'المشهد الحالي', statusActive: 'نشط', statusSuspended: 'موقوف', statusUnbound: 'غير مرتبط', suspendedError: 'البطاقة موقوفة ولا يمكن تغيير المشهد', invalidMode: 'يتوفر وضع الأعمال أو التواصل فقط', missingUid: 'أدخل معرّف الشريحة', missingCardId: 'أدخل معرّف البطاقة', missingDevice: 'يلزم معرّف الجهاز لتحديث المشهد', applySuccess: 'تم', applyError: 'تعذّر الإكمال', loading: 'جارٍ التحميل...', offlineUnavailable: 'لا يمكن لهذا الجهاز كتابة البطاقة دون اتصال حالياً', }, 'es-ES': { title: 'Modo de escena', desc: 'Usa Negocios en ferias diurnas y Social en eventos nocturnos. Las tarjetas suspendidas no se pueden cambiar.', uidLabel: 'UID del chip', cardIdLabel: 'ID de tarjeta', business: 'Feria diurna', social: 'Evento social', apply: 'Aplicar escena', statusLabel: 'Estado actual', sceneLabel: 'Escena actual', statusActive: 'Activa', statusSuspended: 'Suspendida', statusUnbound: 'Sin vincular', suspendedError: 'La tarjeta está suspendida y no puede cambiar de escena', invalidMode: 'Solo están disponibles las escenas de negocios o social', missingUid: 'Introduce el UID del chip', missingCardId: 'Introduce el ID de la tarjeta', missingDevice: 'Se necesita el ID del dispositivo para actualizar la escena', applySuccess: 'Listo', applyError: 'No se pudo completar', loading: 'Cargando...', offlineUnavailable: 'Este dispositivo aún no puede escribir tarjetas sin conexión', }, 'de-DE': { title: 'Szenenmodus', desc: 'Business für Tagesmessen, Social für Abendveranstaltungen. Gesperrte Karten lassen sich nicht wechseln.', uidLabel: 'Chip-UID', cardIdLabel: 'Karten-ID', business: 'Tagesmesse', social: 'Abendempfang', apply: 'Szene anwenden', statusLabel: 'Aktueller Status', sceneLabel: 'Aktuelle Szene', statusActive: 'Aktiv', statusSuspended: 'Gesperrt', statusUnbound: 'Nicht gebunden', suspendedError: 'Die Karte ist gesperrt und kann die Szene nicht wechseln', invalidMode: 'Nur Business- oder Social-Szenen sind verfügbar', missingUid: 'Chip-UID eingeben', missingCardId: 'Karten-ID eingeben', missingDevice: 'Zum Aktualisieren der Szene ist eine Geräte-ID erforderlich', applySuccess: 'Erledigt', applyError: 'Vorgang fehlgeschlagen', loading: 'Wird geladen...', offlineUnavailable: 'Dieses Gerät kann Offline-Visitenkarten noch nicht schreiben', }, 'fr-FR': { title: 'Mode scène', desc: 'Business pour les salons de jour, Social pour les soirées. Les cartes suspendues ne peuvent pas être changées.', uidLabel: 'UID de la puce', cardIdLabel: 'ID de carte', business: 'Salon de jour', social: 'Soirée', apply: 'Appliquer la scène', statusLabel: 'Statut actuel', sceneLabel: 'Scène actuelle', statusActive: 'Active', statusSuspended: 'Suspendue', statusUnbound: 'Non liée', suspendedError: 'Cette carte est suspendue et ne peut pas changer de scène', invalidMode: 'Seules les scènes business ou social sont disponibles', missingUid: 'Saisissez l’UID de la puce', missingCardId: 'Saisissez l’ID de la carte', missingDevice: 'Un identifiant d’appareil est requis pour mettre à jour la scène', applySuccess: 'Terminé', applyError: 'Échec de l’opération', loading: 'Chargement...', offlineUnavailable: 'Cet appareil ne peut pas encore écrire de cartes hors ligne', }, 'ja-JP': { title: 'シーンモード', desc: '日中の展示会はビジネス、夜の交流はソーシャル。利用停止中のカードは切り替えできません。', uidLabel: 'チップ UID', cardIdLabel: '名刺 ID', business: '日中展示会', social: '夜の交流', apply: 'シーンを適用', statusLabel: '現在の状態', sceneLabel: '現在のシーン', statusActive: '有効', statusSuspended: '利用停止', statusUnbound: '未連携', suspendedError: 'このカードは利用停止中のため、シーンを切り替えできません', invalidMode: 'ビジネスまたはソーシャルのみ選択できます', missingUid: 'チップ UID を入力してください', missingCardId: '名刺 ID を入力してください', missingDevice: 'シーンを更新するにはデバイス ID が必要です', applySuccess: '完了しました', applyError: '操作に失敗しました', loading: '読み込み中...', offlineUnavailable: 'この端末ではオフライン名刺を書き込めません', }, }; /** * 场景文案:能复用 locale.uts 的走词典,其余走本地七语表 */ export function resolveModeText(key: ModeCopyKey): string { if (key === 'applySuccess') { return getOmniText(getCurrentLocale(), 'common.success'); } if (key === 'applyError') { return getOmniText(getCurrentLocale(), 'common.error'); } if (key === 'loading') { return getOmniText(getCurrentLocale(), 'common.loading'); } const locale = getCurrentLocale(); const bag = MODE_COPY[locale] != null ? MODE_COPY[locale] : MODE_COPY['en-US']; return bag[key]; } /** * 只放行 business | social */ export function parseSceneMode(raw: string | null | undefined): NFCSceneMode | null { if (raw === 'business' || raw === 'social') { return raw; } return null; } function parseStatus(raw: string | null | undefined): NFCStatus | null { if (raw === 'active' || raw === 'suspended' || raw === 'unbound') { return raw; } return null; } function parseMaterial(raw: string | null | undefined): NFCMaterial | null { if (raw === 'wood' || raw === 'metal' || raw === 'pvc') { return raw; } return null; } function trimText(value: string | number | null | undefined): string { if (value == null) { return ''; } return String(value).trim(); } function toCardNumber(cardId: string | number): number { const n = typeof cardId === 'number' ? cardId : Number(String(cardId).trim()); if (n !== n || n === 0) { return 0; } return n; } function sameCardId( left: string | number | null | undefined, right: string | number | null | undefined ): boolean { const a = trimText(left); const b = trimText(right); if (a === '' || b === '') { return false; } return a === b; } function hasDeviceId(device: NFCRecord | null): boolean { if (device == null) { return false; } return trimText(device.id) !== ''; } function fail(error: string, device: NFCRecord | null): ApplySceneResult { return { ok: false, error, device, }; } function resolveApi(api?: ApplySceneApi): ApplySceneApi { if (api != null) { return api; } return { switchNfcCard, updateNfcDevice, }; } /** * 切换场景:挂失拒绝;换绑仅 active 且 cardId 不同;始终 update 写 sceneMode */ export async function applyScene( params: ApplySceneParams, api?: ApplySceneApi ): Promise { const uid = trimText(params.uid); if (uid === '') { return fail(resolveModeText('missingUid'), params.device); } const mode = parseSceneMode(params.sceneMode); if (mode == null) { return fail(resolveModeText('invalidMode'), params.device); } const cardNumber = toCardNumber(params.cardId); if (cardNumber === 0) { return fail(resolveModeText('missingCardId'), params.device); } const device = params.device; if (device != null && device.status === 'suspended') { return fail(resolveModeText('suspendedError'), device); } if (!hasDeviceId(device)) { return fail(resolveModeText('missingDevice'), device); } const readyDevice = device as NFCRecord; const client = resolveApi(api); let working = readyDevice; const needSwitch = readyDevice.status === 'active' && !sameCardId(readyDevice.cardId, params.cardId); if (needSwitch) { working = await client.switchNfcCard({ uid, cardId: cardNumber, }); } const payload: NFCRecord & { id: string | number } = { id: working.id, uid: working.uid, tenantId: working.tenantId, cardId: working.cardId, batchNo: working.batchNo, material: working.material, status: working.status, sceneMode: mode, lastScannedAt: working.lastScannedAt, }; const updated = await client.updateNfcDevice(payload); return { ok: true, error: '', device: updated, }; } /** * 当前无真实 NDEF 写芯片能力;禁止伪造可写 */ export function canWriteOfflineVCard(): boolean { return false; } /** * 离线 vCard 烧录:无真实写芯片实现时一律失败,不 toast 成功/已烧录 */ export async function writeOfflineVCard( _payload?: OfflineWriteParams ): Promise { const error = resolveModeText('offlineUnavailable'); if (!canWriteOfflineVCard()) { return { ok: false, error, }; } // 真实写芯片接入前不可达;显式失败兜底,杜绝假成功 return { ok: false, error, }; } export function createModeState(): NfcModeState { return { uid: '', cardId: '', selectedMode: 'business', status: '', sceneMode: '', device: null, submitting: false, errorMessage: '', }; } function buildDeviceFromOptions( state: NfcModeState, options: ModePageOptions ): NFCRecord | null { const id = trimText(options.id); if (id === '') { return null; } const status = parseStatus(state.status); const sceneMode = parseSceneMode(state.sceneMode); const material = parseMaterial(options.material); return { id, uid: state.uid, tenantId: trimText(options.tenantId) !== '' ? trimText(options.tenantId) : 0, cardId: state.cardId === '' ? null : state.cardId, batchNo: trimText(options.batchNo), material: material != null ? material : 'pvc', status: status != null ? status : 'unbound', sceneMode: sceneMode != null ? sceneMode : 'business', lastScannedAt: null, }; } /** * 从页面 query 写入 uid / cardId / 当前状态与场景 */ export function applyModeOptions( state: NfcModeState, options: ModePageOptions | null ): void { if (options == null) { return; } const uid = trimText(options.uid); if (uid !== '') { state.uid = uid; } const cardId = trimText(options.cardId); if (cardId !== '') { state.cardId = cardId; } const status = parseStatus(options.status); if (status != null) { state.status = status; } const sceneMode = parseSceneMode(options.sceneMode); if (sceneMode != null) { state.sceneMode = sceneMode; state.selectedMode = sceneMode; } state.device = buildDeviceFromOptions(state, options); } export function selectSceneMode(state: NfcModeState, raw: string): boolean { const mode = parseSceneMode(raw); if (mode == null) { return false; } state.selectedMode = mode; return true; } export function formatStatusLabel(status: string): string { if (status === 'active') { return resolveModeText('statusActive'); } if (status === 'suspended') { return resolveModeText('statusSuspended'); } if (status === 'unbound') { return resolveModeText('statusUnbound'); } return status; } export function formatSceneLabel(mode: string): string { if (mode === 'business') { return resolveModeText('business'); } if (mode === 'social') { return resolveModeText('social'); } return mode; } function toastNone(title: string): void { uni.showToast({ title, icon: 'none', }); } /** * 页面提交:调用 applyScene,成功后回写状态 */ export async function submitApplyScene( state: NfcModeState, api?: ApplySceneApi ): Promise { if (state.submitting) { return { ok: false, error: '', device: state.device, }; } if (state.device != null) { state.device.uid = state.uid; state.device.cardId = state.cardId === '' ? null : state.cardId; } state.submitting = true; state.errorMessage = ''; const result = await applyScene( { uid: state.uid, cardId: state.cardId, sceneMode: state.selectedMode, device: state.device, }, api ); state.submitting = false; if (!result.ok) { state.errorMessage = result.error; toastNone(result.error); return result; } if (result.device != null) { state.device = result.device; state.status = result.device.status; state.sceneMode = result.device.sceneMode; state.uid = result.device.uid; if (result.device.cardId != null && trimText(result.device.cardId) !== '') { state.cardId = String(result.device.cardId); } } toastNone(resolveModeText('applySuccess')); return result; }