文件
OmniCard/mpuniapp/pages/portal/useArticleDetail.uts
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

837 行
20 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* OmniCard AI (CRMmp) - 深度洞见文章详情业务驱动层
* 严格遵循 uts 强类型规范与 AGENTS.md 约束
*/
import { BentoBlockPublic, CardPortalDetail, RadarTrack } from '../../common/types.uts';
import { fetchPortalCard, recordRadarTrack } from '../../common/api.uts';
import { getCurrentLocale, getOmniText, OmniLocale } from '../../common/locale.uts';
export type ArticleDetailPageOptions = {
cardId?: string;
blockId?: string;
id?: string;
};
export type ArticleOpenGraph = {
title: string;
description: string;
image: string;
};
export type ArticleShareFields = {
ctaLabel: string;
copiedToast: string;
shareUrl: string;
title: string;
description: string;
image: string;
};
export type ArticleDetailState = {
cardId: string;
blockId: string;
loading: boolean;
article: BentoBlockPublic | null;
title: string;
content: string;
mediaUrl: string;
linkUrl: string;
isHtmlContent: boolean;
empty: boolean;
errorMessage: string;
openGraph: ArticleOpenGraph;
authorName: string;
authorTitle: string;
authorCompany: string;
authorAvatar: string;
readTimeMinutes: number;
summaryText: string;
scrollDepth: number;
enterTime: number;
visitorId: string;
};
const BACK_LABEL = '返回';
const OG_DESC_MAX = 160;
const VISITOR_STORAGE_KEY = 'omni_visitor_id';
/**
* 创建文章详情初始状态
*/
export function createArticleDetailState(): ArticleDetailState {
return {
cardId: '',
blockId: '',
loading: false,
article: null,
title: '',
content: '',
mediaUrl: '',
linkUrl: '',
isHtmlContent: false,
empty: false,
errorMessage: '',
openGraph: {
title: '',
description: '',
image: '',
},
authorName: '',
authorTitle: '',
authorCompany: '',
authorAvatar: '',
readTimeMinutes: 1,
summaryText: '',
scrollDepth: 0,
enterTime: Date.now(),
visitorId: '',
};
}
/**
* 将详情状态复位为空
*/
export function applyEmptyArticle(state: ArticleDetailState): void {
state.article = null;
state.title = '';
state.content = '';
state.mediaUrl = '';
state.linkUrl = '';
state.isHtmlContent = false;
state.empty = true;
state.openGraph = {
title: '',
description: '',
image: '',
};
state.authorName = '';
state.authorTitle = '';
state.authorCompany = '';
state.authorAvatar = '';
state.readTimeMinutes = 1;
state.summaryText = '';
state.scrollDepth = 0;
}
/**
* 从 onLoad options 写入 cardId / blockId
*/
export function applyArticleDetailOptions(
state: ArticleDetailState,
options: ArticleDetailPageOptions | 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();
if (state.cardId === '' || state.blockId === '') {
applyEmptyArticle(state);
return false;
}
return true;
}
/**
* 从 bentoBlocks 中选出指定 article 块
*/
export function pickArticleBlock(
blocks: BentoBlockPublic[] | null | undefined,
blockId: string
): BentoBlockPublic | null {
if (blocks == null || !Array.isArray(blocks) || blocks.length === 0) {
return null;
}
const targetId = blockId != null ? String(blockId).trim() : '';
if (targetId === '') {
return null;
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (block != null && String(block.id) === targetId && block.type === 'article') {
return block;
}
}
return null;
}
/**
* 正文是否像 HTML,供 rich-text / text 分流
*/
export function looksLikeHtml(content: string): boolean {
if (content == null || content.trim() === '') {
return false;
}
return /<\s*\/?\s*[a-zA-Z][^>]*>/.test(content);
}
/**
* 去掉 HTML 标签,折叠空白
*/
export function stripHtmlToText(raw: string): string {
if (raw == null || raw === '') {
return '';
}
return raw.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* 供后续 H5 meta 使用的 OpenGraph 三件套
*/
export function buildArticleOpenGraph(article: BentoBlockPublic | null): ArticleOpenGraph {
if (article == null) {
return {
title: '',
description: '',
image: '',
};
}
const desc = stripHtmlToText(article.content != null ? article.content : '');
let description = desc;
if (description.length > OG_DESC_MAX) {
description = description.substring(0, OG_DESC_MAX - 3) + '...';
}
return {
title: article.title != null ? article.title : '',
description,
image: article.mediaUrl != null ? article.mediaUrl : '',
};
}
/**
* 分享链接:优先块 linkUrl,否则当前页 URL
*/
export function resolveShareUrl(linkUrl: string, pageUrl: string): string {
const fromBlock = linkUrl != null ? linkUrl.trim() : '';
if (fromBlock !== '') {
return fromBlock;
}
return pageUrl != null ? pageUrl.trim() : '';
}
/**
* 构造当前页路径兜底
*/
export function buildArticlePagePath(cardId: string, blockId: string): string {
return `/pages/portal/article-detail?cardId=${cardId}&blockId=${blockId}`;
}
type UniStackPage = {
route?: string;
};
/**
* 读取当前页 URL:优先页面栈,否则构造本地路径
*/
export function getCurrentPageUrl(state: ArticleDetailState): string {
try {
if (typeof getCurrentPages !== 'function') {
return buildArticlePagePath(state.cardId, state.blockId);
}
const pages = getCurrentPages();
if (pages != null && pages.length > 0) {
const page = pages[pages.length - 1] as UniStackPage;
const route = page != null && page.route != null ? String(page.route) : '';
if (route !== '') {
const path = route.startsWith('/') ? route : `/${route}`;
return `${path}?cardId=${state.cardId}&blockId=${state.blockId}`;
}
}
} catch (_e) {}
return buildArticlePagePath(state.cardId, state.blockId);
}
/**
* 分享文案与 OpenGraph 字段(CTA / Toast / URL / title / description / image)
*/
export function getArticleShareFields(
state: ArticleDetailState,
pageUrl: string
): ArticleShareFields {
const og = state.openGraph;
return {
ctaLabel: resolveArticleDetailText('shareCta'),
copiedToast: getOmniText(getCurrentLocale(), 'portal.copied_link'),
shareUrl: resolveShareUrl(state.linkUrl, pageUrl),
title: og.title,
description: og.description,
image: og.image,
};
}
export function getArticleBackLabel(): string {
return BACK_LABEL;
}
export function getArticleEmptyMessage(): string {
return getOmniText(getCurrentLocale(), 'common.empty');
}
export type ArticleDetailTextKey =
| 'kicker'
| 'shareCta'
| 'shareSuccess'
| 'shareFailed'
| 'copyFailed'
| 'back'
| 'empty'
| 'emptyHint'
| 'summary'
| 'readTime'
| 'minRead'
| 'author'
| 'viewPortal';
/**
* 文章详情展示文案。严格遵循 AGENTS.md 多语言自然表达规范。
*/
export function resolveArticleDetailText(
key: ArticleDetailTextKey,
customLocale?: OmniLocale
): string {
const locale = customLocale != null ? customLocale : getCurrentLocale();
if (key === 'empty') {
return getOmniText(locale, 'common.empty');
}
if (locale === 'zh-CN') {
if (key === 'kicker') {
return '深度洞见';
}
if (key === 'summary') {
return '核心导读';
}
if (key === 'readTime') {
return '预计阅读';
}
if (key === 'minRead') {
return '分钟';
}
if (key === 'author') {
return '专栏作者';
}
if (key === 'viewPortal') {
return '查看微官网';
}
if (key === 'shareCta') {
return '复制分享链接';
}
if (key === 'shareSuccess') {
return '已分享';
}
if (key === 'shareFailed') {
return '分享失败';
}
if (key === 'copyFailed') {
return '复制失败';
}
if (key === 'back') {
return '返回';
}
return '返回上一页继续浏览';
}
if (locale === 'ar-SA') {
if (key === 'kicker') {
return 'رؤى متعمقة';
}
if (key === 'summary') {
return 'أبرز النقاط';
}
if (key === 'readTime') {
return 'وقت القراءة';
}
if (key === 'minRead') {
return 'دقائق';
}
if (key === 'author') {
return 'كاتب المقال';
}
if (key === 'viewPortal') {
return 'عرض الملف التعريفي';
}
if (key === 'shareCta') {
return 'نسخ رابط المشاركة';
}
if (key === 'shareSuccess') {
return 'تمت المشاركة';
}
if (key === 'shareFailed') {
return 'فشلت المشاركة';
}
if (key === 'copyFailed') {
return 'فشل النسخ';
}
if (key === 'back') {
return 'رجوع';
}
return 'ارجع للصفحة السابقة للمتابعة';
}
if (locale === 'es-ES') {
if (key === 'kicker') {
return 'Perspectivas';
}
if (key === 'summary') {
return 'Puntos clave';
}
if (key === 'readTime') {
return 'Tiempo de lectura';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Autor';
}
if (key === 'viewPortal') {
return 'Ver perfil';
}
if (key === 'shareCta') {
return 'Copiar enlace para compartir';
}
if (key === 'shareSuccess') {
return 'Compartido';
}
if (key === 'shareFailed') {
return 'Error al compartir';
}
if (key === 'copyFailed') {
return 'Error al copiar';
}
if (key === 'back') {
return 'Volver';
}
return 'Vuelve para seguir leyendo';
}
if (locale === 'de-DE') {
if (key === 'kicker') {
return 'Brancheneinblick';
}
if (key === 'summary') {
return 'Kernaussagen';
}
if (key === 'readTime') {
return 'Lesezeit';
}
if (key === 'minRead') {
return 'Min.';
}
if (key === 'author') {
return 'Autor';
}
if (key === 'viewPortal') {
return 'Profil ansehen';
}
if (key === 'shareCta') {
return 'Freigabelink kopieren';
}
if (key === 'shareSuccess') {
return 'Geteilt';
}
if (key === 'shareFailed') {
return 'Teilen fehlgeschlagen';
}
if (key === 'copyFailed') {
return 'Kopieren fehlgeschlagen';
}
if (key === 'back') {
return 'Zurück';
}
return 'Gehen Sie zurück, um weiterzulesen';
}
if (locale === 'fr-FR') {
if (key === 'kicker') {
return 'Éclairage d’expert';
}
if (key === 'summary') {
return 'Points clés';
}
if (key === 'readTime') {
return 'Temps de lecture';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Auteur';
}
if (key === 'viewPortal') {
return 'Voir le profil';
}
if (key === 'shareCta') {
return 'Copier le lien de partage';
}
if (key === 'shareSuccess') {
return 'Partagé';
}
if (key === 'shareFailed') {
return 'Échec du partage';
}
if (key === 'copyFailed') {
return 'Échec de la copie';
}
if (key === 'back') {
return 'Retour';
}
return 'Revenez en arrière pour continuer';
}
if (locale === 'ja-JP') {
if (key === 'kicker') {
return 'インサイト';
}
if (key === 'summary') {
return '要約';
}
if (key === 'readTime') {
return '読了目安';
}
if (key === 'minRead') {
return '分';
}
if (key === 'author') {
return '執筆者';
}
if (key === 'viewPortal') {
return 'プロフィールを見る';
}
if (key === 'shareCta') {
return '共有リンクをコピー';
}
if (key === 'shareSuccess') {
return '共有しました';
}
if (key === 'shareFailed') {
return '共有に失敗しました';
}
if (key === 'copyFailed') {
return 'コピーに失敗しました';
}
if (key === 'back') {
return '戻る';
}
return '前のページに戻って続きを見る';
}
if (key === 'kicker') {
return 'Thought Leadership';
}
if (key === 'summary') {
return 'Key Takeaways';
}
if (key === 'readTime') {
return 'Read time';
}
if (key === 'minRead') {
return 'min';
}
if (key === 'author') {
return 'Author';
}
if (key === 'viewPortal') {
return 'View Profile';
}
if (key === 'shareCta') {
return 'Copy share link';
}
if (key === 'shareSuccess') {
return 'Shared';
}
if (key === 'shareFailed') {
return 'Share failed';
}
if (key === 'copyFailed') {
return 'Copy failed';
}
if (key === 'back') {
return 'Back';
}
return 'Go back to keep reading';
}
function applyArticle(
state: ArticleDetailState,
article: BentoBlockPublic,
card?: CardPortalDetail | null
): void {
state.article = article;
state.title = article.title != null ? article.title : '';
state.content = article.content != null ? article.content : '';
state.mediaUrl = article.mediaUrl != null ? article.mediaUrl : '';
state.linkUrl = article.linkUrl != null ? article.linkUrl : '';
state.isHtmlContent = looksLikeHtml(state.content);
state.empty = false;
state.openGraph = buildArticleOpenGraph(article);
const cleanText = stripHtmlToText(state.content);
state.readTimeMinutes = Math.max(1, Math.ceil(cleanText.length / 350));
state.summaryText = state.openGraph.description;
if (card != null) {
state.authorName = card.name != null ? String(card.name) : '';
state.authorTitle = card.title != null ? String(card.title) : '';
state.authorCompany = card.company != null ? String(card.company) : '';
state.authorAvatar = card.avatarUrl != null ? String(card.avatarUrl) : '';
}
}
/**
* fetchPortalCard 后从 bentoBlocks 选出 article 并绑定作者与元信息
*/
export async function loadArticleDetail(
state: ArticleDetailState
): Promise<BentoBlockPublic | null> {
if (state.cardId === '' || state.blockId === '') {
applyEmptyArticle(state);
return null;
}
state.loading = true;
state.errorMessage = '';
state.enterTime = Date.now();
try {
const card = await fetchPortalCard(state.cardId);
const blocks = card != null && card.bentoBlocks != null ? card.bentoBlocks : [];
const article = pickArticleBlock(blocks, state.blockId);
state.loading = false;
if (article == null) {
applyEmptyArticle(state);
return null;
}
applyArticle(state, article, card);
return article;
} catch (err) {
state.loading = false;
applyEmptyArticle(state);
if (err instanceof Error) {
state.errorMessage = err.message;
} else {
state.errorMessage = getOmniText(getCurrentLocale(), 'portal.unavailable');
}
return null;
}
}
/**
* 获取或初始化访客唯一识别码 (持久化在本地 Storage)
*/
export function getOrCreateVisitorId(): string {
let visitorId = '';
try {
visitorId = uni.getStorageSync(VISITOR_STORAGE_KEY) as string;
} catch (_e) {
visitorId = '';
}
if (!visitorId || visitorId.trim() === '') {
const randomPart = Math.random().toString(36).substring(2, 10);
visitorId = `vis_${Date.now()}_${randomPart}`;
try {
uni.setStorageSync(VISITOR_STORAGE_KEY, visitorId);
} catch (_e) {}
}
return visitorId;
}
/**
* 记录并更新滑屏深度 (0 - 100%)
*/
export function updateArticleScrollDepth(
state: ArticleDetailState,
scrollTop: number,
scrollHeight: number,
windowHeight: number
): number {
if (scrollHeight <= 0) {
return state.scrollDepth;
}
const maxScroll = Math.max(1, scrollHeight - windowHeight);
const currentRatio = Math.min(1, Math.max(0, scrollTop / maxScroll));
const depth = Math.round(currentRatio * 100);
if (depth > state.scrollDepth) {
state.scrollDepth = depth;
}
return state.scrollDepth;
}
/**
* 上报文章阅读停留时长与滑屏深度至访客雷达 CRM
*/
export async function reportArticleStay(
state: ArticleDetailState,
scrollDepth?: number
): Promise<RadarTrack | null> {
if (state.cardId === '' || state.blockId === '') {
return null;
}
const currentDepth = scrollDepth != null ? scrollDepth : state.scrollDepth;
const enter = state.enterTime > 0 ? state.enterTime : Date.now();
const stayDurationMs = Math.max(0, Date.now() - enter);
const visitorId = state.visitorId !== '' ? state.visitorId : getOrCreateVisitorId();
try {
const track = await recordRadarTrack({
cardId: state.cardId,
visitorId,
touchType: 'link',
stayDurationMs,
scrollDepth: currentDepth,
viewedBlocks: [state.blockId],
});
return track;
} catch (_e) {
return null;
}
}
/**
* 导航至专栏作者的名片主微官网
*/
export function navToAuthorPortal(state: ArticleDetailState): void {
if (state.cardId === '') {
return;
}
uni.navigateTo({
url: `/pages/portal/index?id=${state.cardId}`,
fail: () => {
uni.reLaunch({
url: `/pages/portal/index?id=${state.cardId}`,
});
},
});
}
type ShareWithSystemOption = {
type?: string;
summary?: string;
href?: string;
success?: () => void;
fail?: (err: { errCode?: number; errMsg?: string } | null) => void;
};
type UniWithSystemShare = {
shareWithSystem?: (options: ShareWithSystemOption) => void;
};
/**
* 是否具备系统分享 API(uni.shareWithSystem)
*/
export function hasArticleSystemShare(): boolean {
const uniShare = uni as UniWithSystemShare;
return uniShare.shareWithSystem != null;
}
/**
* 系统分享:仅 uni.shareWithSystem 明确 success 才提示「已分享」;
* 失败 / 用户取消不得提示已分享。
*/
export function shareArticleViaSystem(
state: ArticleDetailState,
pageUrl?: string
): Promise<boolean> {
const fallback = pageUrl != null ? pageUrl : getCurrentPageUrl(state);
const shareUrl = resolveShareUrl(state.linkUrl, fallback);
if (shareUrl === '') {
return Promise.resolve(false);
}
const uniShare = uni as UniWithSystemShare;
if (uniShare.shareWithSystem == null) {
return Promise.resolve(false);
}
const summary = state.title !== '' ? state.title : shareUrl;
return new Promise<boolean>(resolve => {
uniShare.shareWithSystem!({
type: 'text',
summary: summary,
href: shareUrl,
success: () => {
uni.showToast({
title: resolveArticleDetailText('shareSuccess'),
icon: 'none',
});
resolve(true);
},
fail: (err: { errCode?: number; errMsg?: string } | null) => {
// 取消(1310600)静默;其它失败只提示 shareFailed
const code = err != null && err.errCode != null ? err.errCode : 0;
if (code !== 1310600) {
uni.showToast({
title: resolveArticleDetailText('shareFailed'),
icon: 'none',
});
}
resolve(false);
},
});
});
}
/**
* 主 CTA:复制 linkUrl 或当前页 URL。
* 仅剪贴板 success 才提示直链已复制(P22);失败只提示 copyFailed。
*/
export function copyArticleShareLink(state: ArticleDetailState, pageUrl?: string): boolean {
const fallback = pageUrl != null ? pageUrl : getCurrentPageUrl(state);
const shareUrl = resolveShareUrl(state.linkUrl, fallback);
if (shareUrl === '') {
return false;
}
uni.setClipboardData({
data: shareUrl,
success: () => {
uni.showToast({
title: getOmniText(getCurrentLocale(), 'portal.copied_link'),
icon: 'none',
});
},
fail: () => {
uni.showToast({
title: resolveArticleDetailText('copyFailed'),
icon: 'none',
});
},
});
return true;
}
/**
* 底栏主动作:有系统分享则走系统分享;否则回退复制链接。
* 分享失败/取消不回退复制,避免把失败伪装成已分享或已复制。
*/
export async function performArticleShareAction(
state: ArticleDetailState,
pageUrl?: string
): Promise<boolean> {
if (hasArticleSystemShare()) {
return await shareArticleViaSystem(state, pageUrl);
}
return copyArticleShareLink(state, pageUrl);
}
/**
* 空状态返回:优先回退上一页
*/
export function goBackFromArticle(state: ArticleDetailState): void {
uni.navigateBack({
delta: 1,
fail: () => {
const query = state.cardId !== '' ? `?id=${state.cardId}` : '';
uni.reLaunch({
url: `/pages/portal/index${query}`,
});
},
});
}