补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。 Co-authored-by: Cursor <cursoragent@cursor.com>
528 行
13 KiB
Plaintext
528 行
13 KiB
Plaintext
/**
|
||
* OmniCard AI (CRMmp) - 案例作品集详情业务驱动层
|
||
* 严格遵循 uts 强类型规范与 AGENTS.md 约束
|
||
*/
|
||
|
||
import { CardPortalDetail, BentoBlockPublic } from '../../common/types.uts';
|
||
import { fetchPortalCard } from '../../common/api.uts';
|
||
import { getCurrentLocale, getOmniText } from '../../common/locale.uts';
|
||
|
||
export type CaseKpi = {
|
||
label: string;
|
||
value: string;
|
||
};
|
||
|
||
type KpiLoose = {
|
||
label?: string;
|
||
name?: string;
|
||
title?: string;
|
||
value?: string | number;
|
||
};
|
||
|
||
export type CaseMetaRecord = {
|
||
images?: string[];
|
||
kpis?: KpiLoose[];
|
||
metrics?: KpiLoose[];
|
||
pdfUrl?: string;
|
||
pdf?: string;
|
||
youtubeUrl?: string;
|
||
youtube?: string;
|
||
videoUrl?: string;
|
||
video?: string;
|
||
poster?: string;
|
||
url?: string;
|
||
type?: string;
|
||
client?: string;
|
||
industry?: string;
|
||
region?: string;
|
||
challenge?: string;
|
||
solution?: string;
|
||
};
|
||
|
||
export type CaseDetailState = {
|
||
cardId: string;
|
||
blockId: string;
|
||
loading: boolean;
|
||
empty: boolean;
|
||
errorMessage: string;
|
||
title: string;
|
||
content: string;
|
||
images: string[];
|
||
kpis: CaseKpi[];
|
||
pdfUrl: string;
|
||
youtubeUrl: string;
|
||
videoUrl: string;
|
||
blockType: string;
|
||
client?: string;
|
||
industry?: string;
|
||
region?: string;
|
||
challenge?: string;
|
||
solution?: string;
|
||
};
|
||
|
||
export type CaseDetailPageOptions = {
|
||
cardId?: string;
|
||
blockId?: string;
|
||
id?: string;
|
||
};
|
||
|
||
export type CaseDetailCopyKey =
|
||
| 'empty'
|
||
| 'emptyDesc'
|
||
| 'back'
|
||
| 'cta'
|
||
| 'pdf'
|
||
| 'youtube'
|
||
| 'loading'
|
||
| 'badge'
|
||
| 'videoBadge'
|
||
| 'overview'
|
||
| 'challenge'
|
||
| 'solution'
|
||
| 'kpis'
|
||
| 'resources'
|
||
| 'pdfAction'
|
||
| 'videoAction'
|
||
| 'clientLabel'
|
||
| 'industryLabel'
|
||
| 'regionLabel';
|
||
|
||
export const CASE_DETAIL_COPY: Record<CaseDetailCopyKey, string> = {
|
||
empty: '未找到该案例',
|
||
emptyDesc: '返回上一页继续浏览',
|
||
back: '返回',
|
||
cta: '咨询',
|
||
pdf: 'PDF',
|
||
youtube: '视频',
|
||
loading: '加载中',
|
||
badge: '标杆商务实践',
|
||
videoBadge: '视频演示实况',
|
||
overview: '案例详情与方案',
|
||
challenge: '业务挑战与背景',
|
||
solution: '落地解决方案',
|
||
kpis: '核心商业化成果',
|
||
resources: '项目资料与演示',
|
||
pdfAction: '打开白皮书',
|
||
videoAction: '观看演示视频',
|
||
clientLabel: '标杆客户',
|
||
industryLabel: '行业赛道',
|
||
regionLabel: '业务覆盖区域',
|
||
};
|
||
|
||
function isZhLocale(): boolean {
|
||
return getCurrentLocale() === 'zh-CN';
|
||
}
|
||
|
||
/**
|
||
* 案例详情文案:模板只调本函数,禁止直接绑中文。词典能复用的走 getOmniText。
|
||
*/
|
||
export function resolveCaseDetailText(key: CaseDetailCopyKey): string {
|
||
const locale = getCurrentLocale();
|
||
if (key === 'loading') {
|
||
return getOmniText(locale, 'common.loading');
|
||
}
|
||
|
||
const zh = isZhLocale();
|
||
if (key === 'empty') {
|
||
return zh ? CASE_DETAIL_COPY.empty : 'This case is unavailable';
|
||
}
|
||
if (key === 'emptyDesc') {
|
||
return zh ? CASE_DETAIL_COPY.emptyDesc : 'Go back to keep browsing';
|
||
}
|
||
if (key === 'back') {
|
||
return zh ? CASE_DETAIL_COPY.back : 'Back';
|
||
}
|
||
if (key === 'cta') {
|
||
return zh ? CASE_DETAIL_COPY.cta : 'Inquire';
|
||
}
|
||
if (key === 'badge') {
|
||
return zh ? CASE_DETAIL_COPY.badge : 'GLOBAL CASE STUDY';
|
||
}
|
||
if (key === 'videoBadge') {
|
||
return zh ? CASE_DETAIL_COPY.videoBadge : 'FEATURED VIDEO DEMO';
|
||
}
|
||
if (key === 'overview') {
|
||
return zh ? CASE_DETAIL_COPY.overview : 'Case Overview';
|
||
}
|
||
if (key === 'challenge') {
|
||
return zh ? CASE_DETAIL_COPY.challenge : 'Business Challenge';
|
||
}
|
||
if (key === 'solution') {
|
||
return zh ? CASE_DETAIL_COPY.solution : 'Solution & Architecture';
|
||
}
|
||
if (key === 'kpis') {
|
||
return zh ? CASE_DETAIL_COPY.kpis : 'Key Business Results';
|
||
}
|
||
if (key === 'resources') {
|
||
return zh ? CASE_DETAIL_COPY.resources : 'Project Resources';
|
||
}
|
||
if (key === 'pdfAction') {
|
||
return zh ? CASE_DETAIL_COPY.pdfAction : 'Open whitepaper';
|
||
}
|
||
if (key === 'videoAction') {
|
||
return zh ? CASE_DETAIL_COPY.videoAction : 'Watch demo video';
|
||
}
|
||
if (key === 'pdf') {
|
||
return 'PDF';
|
||
}
|
||
if (key === 'youtube') {
|
||
return zh ? '视频' : 'Video';
|
||
}
|
||
if (key === 'clientLabel') {
|
||
return zh ? CASE_DETAIL_COPY.clientLabel : 'Client';
|
||
}
|
||
if (key === 'industryLabel') {
|
||
return zh ? CASE_DETAIL_COPY.industryLabel : 'Industry';
|
||
}
|
||
if (key === 'regionLabel') {
|
||
return zh ? CASE_DETAIL_COPY.regionLabel : 'Region';
|
||
}
|
||
return CASE_DETAIL_COPY[key];
|
||
}
|
||
|
||
/**
|
||
* 打开案例外链:优先 window.open(对齐 useLogin / useLeadDetail),
|
||
* 无 H5 open 能力时回退复制;空 url 不渲染、不打开。
|
||
*/
|
||
export function openCaseExternalLink(url: string): boolean {
|
||
const safeUrl = url != null ? url.trim() : '';
|
||
if (safeUrl === '') {
|
||
return false;
|
||
}
|
||
|
||
let opened = false;
|
||
try {
|
||
if (typeof window !== 'undefined' && typeof window.open === 'function') {
|
||
window.open(safeUrl, '_blank');
|
||
opened = true;
|
||
}
|
||
} catch (_e) {}
|
||
|
||
if (opened) {
|
||
return true;
|
||
}
|
||
|
||
try {
|
||
uni.setClipboardData({
|
||
data: safeUrl,
|
||
success: () => {
|
||
uni.showToast({
|
||
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
|
||
icon: 'none',
|
||
});
|
||
},
|
||
});
|
||
return true;
|
||
} catch (_e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建案例详情初始状态
|
||
*/
|
||
export function createCaseDetailState(): CaseDetailState {
|
||
return {
|
||
cardId: '',
|
||
blockId: '',
|
||
loading: false,
|
||
empty: false,
|
||
errorMessage: '',
|
||
title: '',
|
||
content: '',
|
||
images: [],
|
||
kpis: [],
|
||
pdfUrl: '',
|
||
youtubeUrl: '',
|
||
videoUrl: '',
|
||
blockType: 'case',
|
||
client: '',
|
||
industry: '',
|
||
region: '',
|
||
challenge: '',
|
||
solution: '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 从 onLoad options 读取 cardId / blockId
|
||
*/
|
||
export function applyCaseDetailOptions(
|
||
state: CaseDetailState,
|
||
options: CaseDetailPageOptions | null
|
||
): boolean {
|
||
const cardIdRaw = options != null ? options.cardId || options.id || '' : '';
|
||
const blockIdRaw = options != null ? options.blockId || '' : '';
|
||
state.cardId = String(cardIdRaw).trim();
|
||
state.blockId = String(blockIdRaw).trim();
|
||
return state.cardId !== '' && state.blockId !== '';
|
||
}
|
||
|
||
/**
|
||
* 从门户聚合数据中选出 id 匹配且 type 为 case 或 video 的块;缺块返回 null
|
||
*/
|
||
export function pickCaseBlock(
|
||
card: CardPortalDetail | null,
|
||
blockId: string
|
||
): BentoBlockPublic | null {
|
||
if (card == null || card.bentoBlocks == null || blockId === '') {
|
||
return null;
|
||
}
|
||
const target = String(blockId);
|
||
const blocks = card.bentoBlocks;
|
||
for (let i = 0; i < blocks.length; i++) {
|
||
const block = blocks[i];
|
||
if (String(block.id) === target && (block.type === 'case' || block.type === 'video')) {
|
||
return block;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 解析 metaJson(字符串或对象)
|
||
*/
|
||
export function parseCaseMetaJson(raw: string | object | null | undefined): CaseMetaRecord {
|
||
if (raw == null) {
|
||
return {};
|
||
}
|
||
if (typeof raw === 'string') {
|
||
const trimmed = raw.trim();
|
||
if (trimmed === '') {
|
||
return {};
|
||
}
|
||
try {
|
||
return JSON.parse(trimmed) as CaseMetaRecord;
|
||
} catch (_e) {
|
||
return {};
|
||
}
|
||
}
|
||
return raw as CaseMetaRecord;
|
||
}
|
||
|
||
function readTrimmed(value: string | undefined): string {
|
||
return value != null ? String(value).trim() : '';
|
||
}
|
||
|
||
function normalizeKpis(items: KpiLoose[] | undefined): CaseKpi[] {
|
||
if (items == null || !Array.isArray(items)) {
|
||
return [];
|
||
}
|
||
const result: CaseKpi[] = [];
|
||
for (let i = 0; i < items.length; i++) {
|
||
const item = items[i];
|
||
if (item == null) {
|
||
continue;
|
||
}
|
||
const label = readTrimmed(item.label) || readTrimmed(item.name) || readTrimmed(item.title);
|
||
const value = item.value != null ? String(item.value).trim() : '';
|
||
if (label === '' && value === '') {
|
||
continue;
|
||
}
|
||
result.push({
|
||
label: label !== '' ? label : value,
|
||
value: value !== '' ? value : label,
|
||
});
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function collectImages(mediaUrl: string, meta: CaseMetaRecord): string[] {
|
||
const result: string[] = [];
|
||
if (meta.images != null && Array.isArray(meta.images)) {
|
||
for (let i = 0; i < meta.images.length; i++) {
|
||
const src = String(meta.images[i]).trim();
|
||
if (src !== '') {
|
||
result.push(src);
|
||
}
|
||
}
|
||
}
|
||
if (result.length === 0) {
|
||
const poster = readTrimmed(meta.poster);
|
||
if (poster !== '') {
|
||
result.push(poster);
|
||
}
|
||
}
|
||
if (result.length === 0 && mediaUrl !== '') {
|
||
result.push(mediaUrl);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function resolvePdfUrl(meta: CaseMetaRecord): string {
|
||
const pdfUrl = readTrimmed(meta.pdfUrl);
|
||
if (pdfUrl !== '') {
|
||
return pdfUrl;
|
||
}
|
||
const pdf = readTrimmed(meta.pdf);
|
||
if (pdf !== '') {
|
||
return pdf;
|
||
}
|
||
const url = readTrimmed(meta.url);
|
||
const kind = readTrimmed(meta.type).toLowerCase();
|
||
if (url !== '' && (kind === 'pdf' || url.toLowerCase().indexOf('.pdf') >= 0)) {
|
||
return url;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function resolveVideoDirectUrl(meta: CaseMetaRecord, block: BentoBlockPublic): string {
|
||
const metaVideo = readTrimmed(meta.videoUrl) || readTrimmed(meta.video);
|
||
if (metaVideo !== '') {
|
||
const lower = metaVideo.toLowerCase();
|
||
if (lower.indexOf('youtube.com') < 0 && lower.indexOf('youtu.be') < 0) {
|
||
return metaVideo;
|
||
}
|
||
}
|
||
const link = block.linkUrl != null ? String(block.linkUrl).trim() : '';
|
||
if (link !== '') {
|
||
const lower = link.toLowerCase();
|
||
if (lower.indexOf('youtube.com') < 0 && lower.indexOf('youtu.be') < 0) {
|
||
return link;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function resolveYoutubeUrl(meta: CaseMetaRecord, block?: BentoBlockPublic): string {
|
||
const youtubeUrl = readTrimmed(meta.youtubeUrl);
|
||
if (youtubeUrl !== '') {
|
||
return youtubeUrl;
|
||
}
|
||
const youtube = readTrimmed(meta.youtube);
|
||
if (youtube !== '') {
|
||
return youtube;
|
||
}
|
||
const url = readTrimmed(meta.url) || readTrimmed(meta.videoUrl);
|
||
const kind = readTrimmed(meta.type).toLowerCase();
|
||
const lower = url.toLowerCase();
|
||
if (
|
||
url !== '' &&
|
||
(kind === 'youtube' || lower.indexOf('youtube.com') >= 0 || lower.indexOf('youtu.be') >= 0)
|
||
) {
|
||
return url;
|
||
}
|
||
if (block != null && block.linkUrl != null) {
|
||
const link = String(block.linkUrl).trim();
|
||
const linkLower = link.toLowerCase();
|
||
if (linkLower.indexOf('youtube.com') >= 0 || linkLower.indexOf('youtu.be') >= 0) {
|
||
return link;
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function clearCaseContent(state: CaseDetailState): void {
|
||
state.title = '';
|
||
state.content = '';
|
||
state.images = [];
|
||
state.kpis = [];
|
||
state.pdfUrl = '';
|
||
state.youtubeUrl = '';
|
||
state.videoUrl = '';
|
||
state.blockType = 'case';
|
||
state.client = '';
|
||
state.industry = '';
|
||
state.region = '';
|
||
state.challenge = '';
|
||
state.solution = '';
|
||
}
|
||
|
||
function applyEmpty(state: CaseDetailState): void {
|
||
state.loading = false;
|
||
state.empty = true;
|
||
clearCaseContent(state);
|
||
}
|
||
|
||
function fillFromBlock(state: CaseDetailState, block: BentoBlockPublic): void {
|
||
const meta = parseCaseMetaJson(block.metaJson);
|
||
const mediaUrl = block.mediaUrl != null ? block.mediaUrl.trim() : '';
|
||
state.empty = false;
|
||
state.loading = false;
|
||
state.errorMessage = '';
|
||
state.blockType = block.type != null ? String(block.type) : 'case';
|
||
state.title = block.title != null ? block.title : '';
|
||
state.content = block.content != null ? block.content : '';
|
||
state.images = collectImages(mediaUrl, meta);
|
||
const kpis = normalizeKpis(meta.kpis);
|
||
state.kpis = kpis.length > 0 ? kpis : normalizeKpis(meta.metrics);
|
||
state.pdfUrl = resolvePdfUrl(meta);
|
||
state.videoUrl = resolveVideoDirectUrl(meta, block);
|
||
state.youtubeUrl = resolveYoutubeUrl(meta, block);
|
||
state.client = readTrimmed(meta.client);
|
||
state.industry = readTrimmed(meta.industry);
|
||
state.region = readTrimmed(meta.region);
|
||
state.challenge = readTrimmed(meta.challenge);
|
||
state.solution = readTrimmed(meta.solution);
|
||
}
|
||
|
||
/**
|
||
* 用已拉取的门户数据填充案例详情;缺块返空
|
||
*/
|
||
export function applyPortalCase(
|
||
state: CaseDetailState,
|
||
card: CardPortalDetail | null
|
||
): boolean {
|
||
const block = pickCaseBlock(card, state.blockId);
|
||
if (block == null) {
|
||
applyEmpty(state);
|
||
return false;
|
||
}
|
||
fillFromBlock(state, block);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 拉取门户聚合详情并选出 case 块
|
||
*/
|
||
export async function loadCaseDetail(state: CaseDetailState): Promise<boolean> {
|
||
if (state.cardId === '' || state.blockId === '') {
|
||
applyEmpty(state);
|
||
return false;
|
||
}
|
||
|
||
state.loading = true;
|
||
state.empty = false;
|
||
state.errorMessage = '';
|
||
|
||
try {
|
||
const card = await fetchPortalCard(state.cardId);
|
||
return applyPortalCase(state, card);
|
||
} catch (err) {
|
||
applyEmpty(state);
|
||
if (err instanceof Error) {
|
||
state.errorMessage = err.message;
|
||
} else {
|
||
state.errorMessage = '获取案例详情失败';
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 咨询 / 互换页路径
|
||
*/
|
||
export function buildExchangePath(cardId: string): string {
|
||
return `/pages/portal/exchange?cardId=${cardId}`;
|
||
}
|
||
|
||
/**
|
||
* 跳转无摩擦互换名片
|
||
*/
|
||
export function openCaseExchange(cardId: string): void {
|
||
const id = cardId != null ? cardId.trim() : '';
|
||
if (id === '') {
|
||
return;
|
||
}
|
||
uni.navigateTo({
|
||
url: buildExchangePath(id),
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 空状态返回上一页
|
||
*/
|
||
export function goCaseBack(): void {
|
||
uni.navigateBack();
|
||
}
|