补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
394 行
9.6 KiB
Plaintext
394 行
9.6 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 名片主参展分享码海报业务驱动层
|
||
* 解析 slug / id,拉取公开短链二维码,复制链接,保存海报到相册
|
||
*/
|
||
|
||
import { fetchCardQrcodeSvg } from '../../../common/api.uts';
|
||
import {
|
||
getCurrentLocale,
|
||
getOmniText,
|
||
OmniLocale,
|
||
} from '../../../common/locale.uts';
|
||
|
||
export type QrcodeQuery = {
|
||
slug?: string;
|
||
id?: string;
|
||
};
|
||
|
||
export type CardQrcodePoster = {
|
||
svg: string;
|
||
url: string;
|
||
};
|
||
|
||
export type LoadPosterApi = {
|
||
fetchCardQrcodeSvg: (slugOrId: string) => Promise<CardQrcodePoster>;
|
||
};
|
||
|
||
export type LoadPosterResult = {
|
||
svg: string;
|
||
url: string;
|
||
empty: boolean;
|
||
errorMessage: string;
|
||
};
|
||
|
||
export type QrcodeState = {
|
||
slugOrId: string;
|
||
svg: string;
|
||
url: string;
|
||
loading: boolean;
|
||
empty: boolean;
|
||
errorMessage: string;
|
||
};
|
||
|
||
export type QrcodeCopyKey =
|
||
| 'copyCta'
|
||
| 'generated'
|
||
| 'urlLabel'
|
||
| 'empty'
|
||
| 'error'
|
||
| 'loading'
|
||
| 'title'
|
||
| 'saveSuccess'
|
||
| 'saveFailed'
|
||
| 'saveUnavailable';
|
||
|
||
const QRCODE_COPY: Record<OmniLocale, Record<QrcodeCopyKey, string>> = {
|
||
'zh-CN': {
|
||
title: '分享名片',
|
||
copyCta: '复制链接',
|
||
generated: '二维码已生成',
|
||
urlLabel: '短链',
|
||
empty: '暂无数据',
|
||
error: '出错了',
|
||
loading: '加载中...',
|
||
saveSuccess: '已保存到相册',
|
||
saveFailed: '保存失败',
|
||
saveUnavailable: '暂无可保存的图片',
|
||
},
|
||
'en-US': {
|
||
title: 'Share Card',
|
||
copyCta: 'Copy link',
|
||
generated: 'QR code ready',
|
||
urlLabel: 'Short link',
|
||
empty: 'Nothing here yet',
|
||
error: 'Something went wrong',
|
||
loading: 'Loading...',
|
||
saveSuccess: 'Saved to Photos',
|
||
saveFailed: 'Could not save',
|
||
saveUnavailable: 'No image to save',
|
||
},
|
||
'ar-SA': {
|
||
title: 'مشاركة البطاقة',
|
||
copyCta: 'نسخ الرابط',
|
||
generated: 'رمز الاستجابة جاهز',
|
||
urlLabel: 'الرابط المختصر',
|
||
empty: 'لا توجد بيانات بعد',
|
||
error: 'حدث خطأ',
|
||
loading: 'جار التحميل...',
|
||
saveSuccess: 'تم الحفظ في الصور',
|
||
saveFailed: 'تعذر الحفظ',
|
||
saveUnavailable: 'لا توجد صورة للحفظ',
|
||
},
|
||
'es-ES': {
|
||
title: 'Compartir tarjeta',
|
||
copyCta: 'Copiar enlace',
|
||
generated: 'Codigo QR listo',
|
||
urlLabel: 'Enlace corto',
|
||
empty: 'Aun no hay datos',
|
||
error: 'Algo salio mal',
|
||
loading: 'Cargando...',
|
||
saveSuccess: 'Guardado en Fotos',
|
||
saveFailed: 'No se pudo guardar',
|
||
saveUnavailable: 'No hay imagen para guardar',
|
||
},
|
||
'de-DE': {
|
||
title: 'Karte teilen',
|
||
copyCta: 'Link kopieren',
|
||
generated: 'QR-Code bereit',
|
||
urlLabel: 'Kurzlink',
|
||
empty: 'Noch keine Daten',
|
||
error: 'Etwas ist schiefgelaufen',
|
||
loading: 'Wird geladen...',
|
||
saveSuccess: 'In Fotos gespeichert',
|
||
saveFailed: 'Speichern fehlgeschlagen',
|
||
saveUnavailable: 'Kein Bild zum Speichern',
|
||
},
|
||
'fr-FR': {
|
||
title: 'Partager la carte',
|
||
copyCta: 'Copier le lien',
|
||
generated: 'QR code pret',
|
||
urlLabel: 'Lien court',
|
||
empty: 'Aucune donnee',
|
||
error: 'Une erreur est survenue',
|
||
loading: 'Chargement...',
|
||
saveSuccess: 'Enregistre dans Photos',
|
||
saveFailed: 'Echec de l enregistrement',
|
||
saveUnavailable: 'Aucune image a enregistrer',
|
||
},
|
||
'ja-JP': {
|
||
title: '名刺をシェア',
|
||
copyCta: 'リンクをコピー',
|
||
generated: 'QRコードを生成しました',
|
||
urlLabel: 'ショートリンク',
|
||
empty: 'データがありません',
|
||
error: 'エラーが発生しました',
|
||
loading: '読み込み中...',
|
||
saveSuccess: '写真に保存しました',
|
||
saveFailed: '保存に失敗しました',
|
||
saveUnavailable: '保存できる画像がありません',
|
||
},
|
||
};
|
||
|
||
function trimText(value: string | number | null | undefined): string {
|
||
if (value == null) {
|
||
return '';
|
||
}
|
||
return String(value).trim();
|
||
}
|
||
|
||
/**
|
||
* 页面文案:能复用 locale.uts 的走词典,其余走本地七语表
|
||
*/
|
||
export function resolveQrcodeText(key: QrcodeCopyKey): string {
|
||
if (key === 'title') {
|
||
return getOmniText(getCurrentLocale(), 'card.share');
|
||
}
|
||
if (key === 'empty') {
|
||
return getOmniText(getCurrentLocale(), 'common.empty');
|
||
}
|
||
if (key === 'error') {
|
||
return getOmniText(getCurrentLocale(), 'common.error');
|
||
}
|
||
if (key === 'loading') {
|
||
return getOmniText(getCurrentLocale(), 'common.loading');
|
||
}
|
||
const locale = getCurrentLocale();
|
||
const bag = QRCODE_COPY[locale] != null ? QRCODE_COPY[locale] : QRCODE_COPY['en-US'];
|
||
return bag[key];
|
||
}
|
||
|
||
/**
|
||
* 路由查询优先 slug,否则 id
|
||
*/
|
||
export function resolveSlugOrId(query: QrcodeQuery | null | undefined): string {
|
||
if (query == null) {
|
||
return '';
|
||
}
|
||
const slug = trimText(query.slug);
|
||
if (slug !== '') {
|
||
return slug;
|
||
}
|
||
return trimText(query.id);
|
||
}
|
||
|
||
function resolvePosterApi(api?: LoadPosterApi): LoadPosterApi {
|
||
if (api != null) {
|
||
return api;
|
||
}
|
||
return {
|
||
fetchCardQrcodeSvg,
|
||
};
|
||
}
|
||
|
||
export function createQrcodeState(): QrcodeState {
|
||
return {
|
||
slugOrId: '',
|
||
svg: '',
|
||
url: '',
|
||
loading: false,
|
||
empty: false,
|
||
errorMessage: '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 拉取公开 SVG 海报:可注入 fetchCardQrcodeSvg
|
||
*/
|
||
export async function loadPoster(
|
||
slugOrId: string | null | undefined,
|
||
api?: LoadPosterApi
|
||
): Promise<LoadPosterResult> {
|
||
const key = trimText(slugOrId);
|
||
if (key === '') {
|
||
return {
|
||
svg: '',
|
||
url: '',
|
||
empty: true,
|
||
errorMessage: '',
|
||
};
|
||
}
|
||
|
||
try {
|
||
const poster = await resolvePosterApi(api).fetchCardQrcodeSvg(key);
|
||
const svg = poster != null && poster.svg != null ? String(poster.svg) : '';
|
||
const url = poster != null && poster.url != null ? String(poster.url) : '';
|
||
return {
|
||
svg,
|
||
url,
|
||
empty: svg === '' && url === '',
|
||
errorMessage: '',
|
||
};
|
||
} catch (err) {
|
||
const message =
|
||
err instanceof Error
|
||
? err.message
|
||
: getOmniText(getCurrentLocale(), 'common.error');
|
||
return {
|
||
svg: '',
|
||
url: '',
|
||
empty: false,
|
||
errorMessage: message,
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function loadPosterIntoState(
|
||
state: QrcodeState,
|
||
slugOrId: string | null | undefined,
|
||
api?: LoadPosterApi
|
||
): Promise<LoadPosterResult> {
|
||
state.slugOrId = trimText(slugOrId);
|
||
state.loading = true;
|
||
state.errorMessage = '';
|
||
const result = await loadPoster(state.slugOrId, api);
|
||
state.svg = result.svg;
|
||
state.url = result.url;
|
||
state.empty = result.empty;
|
||
state.errorMessage = result.errorMessage;
|
||
state.loading = false;
|
||
return result;
|
||
}
|
||
|
||
export function applyQrcodeOptions(
|
||
state: QrcodeState,
|
||
options: QrcodeQuery | null | undefined
|
||
): string {
|
||
const slugOrId = resolveSlugOrId(options);
|
||
state.slugOrId = slugOrId;
|
||
return slugOrId;
|
||
}
|
||
|
||
/**
|
||
* 主 CTA:复制短链
|
||
*/
|
||
export function copyPosterUrl(url: string | null | undefined): boolean {
|
||
const text = trimText(url);
|
||
if (text === '') {
|
||
return false;
|
||
}
|
||
|
||
uni.setClipboardData({
|
||
data: text,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 仅本地路径 / 网络图 / data:image 可入库;纯 SVG 标记不可直接进相册
|
||
*/
|
||
export function isSaveablePosterImage(source: string | null | undefined): boolean {
|
||
const text = trimText(source);
|
||
if (text === '') {
|
||
return false;
|
||
}
|
||
const lower = text.toLowerCase();
|
||
if (lower.indexOf('<svg') >= 0 || lower.indexOf('<?xml') === 0) {
|
||
return false;
|
||
}
|
||
if (
|
||
lower.indexOf('http://') === 0 ||
|
||
lower.indexOf('https://') === 0 ||
|
||
lower.indexOf('data:image') === 0
|
||
) {
|
||
return true;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function toastSaveFailed(unavailable: boolean): void {
|
||
if (unavailable) {
|
||
uni.showToast({
|
||
title: resolveQrcodeText('saveUnavailable'),
|
||
icon: 'none',
|
||
});
|
||
return;
|
||
}
|
||
uni.showToast({
|
||
title: resolveQrcodeText('saveFailed'),
|
||
icon: 'none',
|
||
});
|
||
}
|
||
|
||
function invokeSaveImageToPhotosAlbum(filePath: string): Promise<boolean> {
|
||
return new Promise<boolean>(resolve => {
|
||
uni.saveImageToPhotosAlbum({
|
||
filePath: filePath,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: resolveQrcodeText('saveSuccess'),
|
||
icon: 'none',
|
||
});
|
||
resolve(true);
|
||
},
|
||
fail: (_err: unknown) => {
|
||
toastSaveFailed(false);
|
||
resolve(false);
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
function downloadRemoteImage(url: string): Promise<string> {
|
||
return new Promise<string>(resolve => {
|
||
uni.downloadFile({
|
||
url: url,
|
||
success: (res: { statusCode?: number; tempFilePath?: string }) => {
|
||
const path =
|
||
res != null && res.tempFilePath != null
|
||
? String(res.tempFilePath).trim()
|
||
: '';
|
||
const code = res != null && res.statusCode != null ? res.statusCode : 0;
|
||
if (code === 200 && path !== '') {
|
||
resolve(path);
|
||
return;
|
||
}
|
||
resolve('');
|
||
},
|
||
fail: (_err: unknown) => {
|
||
resolve('');
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 保存海报到相册:必须等 uni.saveImageToPhotosAlbum 成功才 toast 已保存
|
||
*/
|
||
export async function savePosterToAlbum(
|
||
imageSource: string | null | undefined
|
||
): Promise<boolean> {
|
||
const source = trimText(imageSource);
|
||
if (source === '' || !isSaveablePosterImage(source)) {
|
||
toastSaveFailed(true);
|
||
return false;
|
||
}
|
||
|
||
let filePath = source;
|
||
const lower = source.toLowerCase();
|
||
if (lower.indexOf('http://') === 0 || lower.indexOf('https://') === 0) {
|
||
filePath = await downloadRemoteImage(source);
|
||
if (filePath === '') {
|
||
toastSaveFailed(false);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return invokeSaveImageToPhotosAlbum(filePath);
|
||
}
|