feat(mpuniapp): 落地智能销售移动工作台与5栏TabBar纯正架构(工作台、名片、雷达、潜客、我的)
这个提交包含在:
@@ -21,7 +21,7 @@
|
|||||||
function routeOwnerHome() : void {
|
function routeOwnerHome() : void {
|
||||||
if (getAuthToken() != '') {
|
if (getAuthToken() != '') {
|
||||||
uni.switchTab({
|
uni.switchTab({
|
||||||
url: '/pages/owner/card/index'
|
url: '/pages/owner/workplace/index'
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
const hash = window.location.hash
|
const hash = window.location.hash
|
||||||
if (!hash || hash === '#' || hash === '#/' || hash === '') {
|
if (!hash || hash === '#' || hash === '#/' || hash === '') {
|
||||||
if (getAuthToken() != '') {
|
if (getAuthToken() != '') {
|
||||||
window.location.hash = '#/pages/owner/card/index'
|
window.location.hash = '#/pages/owner/workplace/index'
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = '#/pages/common/login'
|
window.location.hash = '#/pages/common/login'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,22 @@ import {
|
|||||||
TeamRankItem,
|
TeamRankItem,
|
||||||
SubmitLeadParams,
|
SubmitLeadParams,
|
||||||
RecordRadarTrackParams,
|
RecordRadarTrackParams,
|
||||||
|
WorkplaceOverviewResult,
|
||||||
} from './types.uts';
|
} from './types.uts';
|
||||||
import { httpGet, httpPost } from './request.uts';
|
import { httpGet, httpPost } from './request.uts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取商务概览工作台数据 (包含今日KPI与雷达高光Feed)
|
||||||
|
* 对齐后端 POST /admin/crm/radar/workplace
|
||||||
|
*/
|
||||||
|
export function fetchWorkplaceOverview(feedLimit?: number): Promise<WorkplaceOverviewResult> {
|
||||||
|
const query: Record<string, number> = {};
|
||||||
|
if (feedLimit != null) {
|
||||||
|
query.feedLimit = feedLimit;
|
||||||
|
}
|
||||||
|
return httpPost<WorkplaceOverviewResult>('/admin/crm/radar/workplace', query);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取微官网与名片公开聚合详情 (包含 Hero、Bento 块、多语言与品牌 VI)
|
* 获取微官网与名片公开聚合详情 (包含 Hero、Bento 块、多语言与品牌 VI)
|
||||||
* 对齐后端 GET /open/card/portal/detail?id= | ?slug= | ?slugOrId=
|
* 对齐后端 GET /open/card/portal/detail?id= | ?slug= | ?slugOrId=
|
||||||
|
|||||||
@@ -342,3 +342,34 @@ export interface CardArticle {
|
|||||||
createTime?: Date | string;
|
createTime?: Date | string;
|
||||||
updateTime?: Date | string;
|
updateTime?: Date | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkplaceKpi {
|
||||||
|
tapCount: number;
|
||||||
|
viewCount: number;
|
||||||
|
newLeadCount: number;
|
||||||
|
conversionRate: number;
|
||||||
|
tapDelta: number;
|
||||||
|
viewDelta: number;
|
||||||
|
newLeadDelta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkplaceRadarItem {
|
||||||
|
id: number | string;
|
||||||
|
cardId: number | string;
|
||||||
|
cardTitle?: string;
|
||||||
|
visitorId?: string;
|
||||||
|
country: string;
|
||||||
|
city: string;
|
||||||
|
touchType: TouchType;
|
||||||
|
stayDurationMs: number;
|
||||||
|
viewedBlocks: string[];
|
||||||
|
intentScore?: number;
|
||||||
|
isLocal?: boolean;
|
||||||
|
createTime: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkplaceOverviewResult {
|
||||||
|
kpis: WorkplaceKpi;
|
||||||
|
radarFeed: WorkplaceRadarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,811 @@
|
|||||||
|
<template>
|
||||||
|
<view class="workplace-page" :class="pageRtlClass" :style="pageTheme">
|
||||||
|
<!-- 顶部受控局部极光微光晕 (面积 < 8%) -->
|
||||||
|
<view class="workplace-aurora-glow"></view>
|
||||||
|
|
||||||
|
<!-- 顶栏:当前名片身份与雷达在线探测状态 -->
|
||||||
|
<view class="workplace-topbar">
|
||||||
|
<view class="workplace-identity" @click="handleSwitchCard">
|
||||||
|
<text class="identity-name">{{ activeCardName }}</text>
|
||||||
|
<text class="identity-badge">{{ activeCardBadge }}</text>
|
||||||
|
<text class="identity-arrow">v</text>
|
||||||
|
</view>
|
||||||
|
<view class="radar-status-pill" @click="handleOpenRadar">
|
||||||
|
<view class="radar-pulse-dot"></view>
|
||||||
|
<text class="radar-status-text">{{ t('workplace.radar_active') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="workplace-scroll" :scroll-y="true">
|
||||||
|
<!-- 问候语横幅 -->
|
||||||
|
<view class="greeting-banner">
|
||||||
|
<text class="greeting-title">{{ greetingTitle }}</text>
|
||||||
|
<text class="greeting-subtitle">{{ t('workplace.greeting') }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 1. 闪电触达微展台 (Quick Showcase) -->
|
||||||
|
<view class="quick-showcase-section">
|
||||||
|
<view class="showcase-card">
|
||||||
|
<view class="showcase-qr-wrap" @click="handleOpenQrcode">
|
||||||
|
<rich-text
|
||||||
|
v-if="state.activeQrSvg !== ''"
|
||||||
|
class="showcase-qr-code"
|
||||||
|
:nodes="state.activeQrSvg"
|
||||||
|
></rich-text>
|
||||||
|
<view v-else class="showcase-qr-placeholder">
|
||||||
|
<view class="qr-pulse-block"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="showcase-meta">
|
||||||
|
<view class="meta-header">
|
||||||
|
<text class="meta-title">{{ activeCardTitle }}</text>
|
||||||
|
<text class="meta-preview-btn" @click="handlePreviewPortal">
|
||||||
|
{{ t('workplace.view_portal') }} ↗
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text class="meta-company">{{ activeCardCompany }}</text>
|
||||||
|
<view class="meta-nfc-tag">
|
||||||
|
<view class="nfc-dot"></view>
|
||||||
|
<text class="nfc-tag-text">{{ t('workplace.nfc_active') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="meta-actions">
|
||||||
|
<button class="copy-link-btn" @click="handleCopyLink">
|
||||||
|
<text class="copy-link-text">{{ t('workplace.copy_link') }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 2. 今日战情报表 (2x2 Bento KPIs) -->
|
||||||
|
<view class="kpi-grid-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">{{ t('workplace.kpi_title') }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="kpi-grid">
|
||||||
|
<view class="kpi-card" @click="handleOpenRadar">
|
||||||
|
<text class="kpi-label">{{ t('workplace.kpi_taps') }}</text>
|
||||||
|
<view class="kpi-value-row">
|
||||||
|
<text class="kpi-value">{{ state.kpis.tapCount }}</text>
|
||||||
|
<text class="kpi-delta">+{{ state.kpis.tapDelta }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="kpi-card" @click="handleOpenRadar">
|
||||||
|
<text class="kpi-label">{{ t('workplace.kpi_visitors') }}</text>
|
||||||
|
<view class="kpi-value-row">
|
||||||
|
<text class="kpi-value">{{ state.kpis.viewCount }}</text>
|
||||||
|
<text class="kpi-delta">+{{ state.kpis.viewDelta }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="kpi-card" @click="handleOpenLeads">
|
||||||
|
<text class="kpi-label">{{ t('workplace.kpi_leads') }}</text>
|
||||||
|
<view class="kpi-value-row">
|
||||||
|
<text class="kpi-value">{{ state.kpis.newLeadCount }}</text>
|
||||||
|
<text class="kpi-delta">+{{ state.kpis.newLeadDelta }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="kpi-card" @click="handleOpenLeads">
|
||||||
|
<text class="kpi-label">{{ t('workplace.kpi_conversion') }}</text>
|
||||||
|
<view class="kpi-value-row">
|
||||||
|
<text class="kpi-value">{{ state.kpis.conversionRate }}%</text>
|
||||||
|
<text class="kpi-tag-hot">HOT</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 3. 实时雷达高光足迹 (Live Radar Hot Pulse) -->
|
||||||
|
<view class="radar-pulse-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">{{ t('workplace.radar_pulse_title') }}</text>
|
||||||
|
<text class="section-action" @click="handleOpenRadar">{{ t('workplace.leads_view_all') }} ↗</text>
|
||||||
|
</view>
|
||||||
|
<view class="pulse-card" v-if="state.hotRadarItem != null" @click="handleOpenRadar">
|
||||||
|
<view class="pulse-header">
|
||||||
|
<view class="pulse-geo">
|
||||||
|
<view class="geo-badge">
|
||||||
|
<text class="geo-code">{{ state.hotRadarItem!.country }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="geo-city">{{ state.hotRadarItem!.city }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="pulse-score-pill">
|
||||||
|
<text class="pulse-score-text">Score {{ state.hotRadarItem!.intentScore || 90 }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="pulse-desc">{{ t('workplace.radar_pulse_desc') }}</text>
|
||||||
|
<view class="pulse-footer">
|
||||||
|
<text class="pulse-time">{{ formatTime(state.hotRadarItem!.createTime) }}</text>
|
||||||
|
<button class="pulse-action-btn" @click.stop="handleQuickContact">
|
||||||
|
<text class="pulse-action-text">{{ t('workplace.radar_action_whatsapp') }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 4. 今日待办商机 (Actionable Leads) -->
|
||||||
|
<view class="leads-todo-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">{{ t('workplace.leads_todo_title') }}</text>
|
||||||
|
<text class="section-action" @click="handleOpenLeads">{{ t('workplace.leads_view_all') }} ↗</text>
|
||||||
|
</view>
|
||||||
|
<view class="leads-flow">
|
||||||
|
<view
|
||||||
|
class="lead-item-card"
|
||||||
|
v-for="lead in state.recentLeads"
|
||||||
|
:key="lead.id"
|
||||||
|
@click="handleOpenLead(lead.id)"
|
||||||
|
>
|
||||||
|
<view class="lead-top">
|
||||||
|
<text class="lead-name">{{ lead.name }}</text>
|
||||||
|
<text class="lead-status-chip" :class="'status-' + lead.status">{{ lead.status.toUpperCase() }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="lead-company">{{ lead.company || 'Global Client' }}</text>
|
||||||
|
<text class="lead-notes">{{ lead.notes || '待跟进商机' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 5. AI 销售副驾驶建议 (AI Co-pilot) -->
|
||||||
|
<view class="ai-copilot-section">
|
||||||
|
<view class="ai-card">
|
||||||
|
<view class="ai-header">
|
||||||
|
<view class="ai-spark-dot"></view>
|
||||||
|
<text class="ai-title">{{ t('workplace.ai_tip_title') }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="ai-desc">{{ t('workplace.ai_tip_desc') }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 底部安全区垫高 -->
|
||||||
|
<view class="workplace-spacer"></view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<!-- 底部常驻单悬浮主 CTA 栏 -->
|
||||||
|
<view class="workplace-cta-bar">
|
||||||
|
<view class="cta-inner">
|
||||||
|
<button class="primary-cta-btn" @click="handleOpenQrcode">
|
||||||
|
<text class="primary-cta-btn-text">{{ t('workplace.primary_cta') }}</text>
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="uts">
|
||||||
|
import { computed, onMounted, reactive } from 'vue';
|
||||||
|
import { getCurrentLocale, isRtl } from '../../../common/locale.uts';
|
||||||
|
import {
|
||||||
|
createWorkplaceState,
|
||||||
|
loadWorkplaceData,
|
||||||
|
openCardPreview,
|
||||||
|
openCardQrcode,
|
||||||
|
openCardsPage,
|
||||||
|
openLeadDetail,
|
||||||
|
openLeadsPage,
|
||||||
|
openRadarPage,
|
||||||
|
resolveWorkplaceText,
|
||||||
|
WorkplaceState,
|
||||||
|
} from './useWorkplace.uts';
|
||||||
|
|
||||||
|
const state = reactive<WorkplaceState>(createWorkplaceState());
|
||||||
|
|
||||||
|
const currentLang = computed((): string => getCurrentLocale());
|
||||||
|
const pageRtlClass = computed((): string => (isRtl(currentLang.value) ? 'rtl-layout' : 'ltr-layout'));
|
||||||
|
const pageTheme = computed((): string => {
|
||||||
|
return '--primary:#0B5CFF;--bg:#F5F8FC;--surface:#FFFFFF;--text:#101828;--text2:#64748B;--border:#E3E7ED;--primary-soft:#EBF5FF;';
|
||||||
|
});
|
||||||
|
|
||||||
|
function t(key: string): string {
|
||||||
|
return resolveWorkplaceText(key, currentLang.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCardName = computed((): string => {
|
||||||
|
return state.activeCard != null ? state.activeCard!.name : 'OmniCard Sales';
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCardTitle = computed((): string => {
|
||||||
|
return state.activeCard != null ? (state.activeCard!.title || 'Senior Consultant') : 'Senior Consultant';
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCardCompany = computed((): string => {
|
||||||
|
return state.activeCard != null ? (state.activeCard!.company || 'OmniCard Global') : 'OmniCard Global';
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCardBadge = computed((): string => {
|
||||||
|
return state.activeCard != null && state.activeCard!.status === 'published' ? 'PRO' : 'CARD';
|
||||||
|
});
|
||||||
|
|
||||||
|
const greetingTitle = computed((): string => {
|
||||||
|
return currentLang.value.startsWith('zh') ? '早安,销售精英' : 'Good day, Sales Partner';
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatTime(time: string | Date | undefined): string {
|
||||||
|
if (time == null) return '刚刚';
|
||||||
|
return '5分钟前';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenQrcode(): void {
|
||||||
|
if (state.activeCard != null) {
|
||||||
|
openCardQrcode(state.activeCard!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewPortal(): void {
|
||||||
|
if (state.activeCard != null) {
|
||||||
|
openCardPreview(state.activeCard!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwitchCard(): void {
|
||||||
|
openCardsPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenRadar(): void {
|
||||||
|
openRadarPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenLeads(): void {
|
||||||
|
openLeadsPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenLead(id: string | number): void {
|
||||||
|
openLeadDetail(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuickContact(): void {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('workplace.radar_action_whatsapp'),
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyLink(): void {
|
||||||
|
const url = state.activeCard != null && state.activeCard!.url != null
|
||||||
|
? state.activeCard!.url!
|
||||||
|
: 'https://omnicard.cc';
|
||||||
|
uni.setClipboardData({
|
||||||
|
data: url,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: t('workplace.link_copied'),
|
||||||
|
icon: 'none',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadWorkplaceData(state);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workplace-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--bg, #F5F8FC);
|
||||||
|
color: var(--text, #101828);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部受控局部极光弥散微光晕 (< 8% 面积) */
|
||||||
|
.workplace-aurora-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 140px;
|
||||||
|
background: linear-gradient(180deg, rgba(11, 92, 255, 0.08) 0%, rgba(245, 248, 252, 0) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏 */
|
||||||
|
.workplace-topbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
height: 48px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
background-color: var(--surface, #FFFFFF);
|
||||||
|
border-bottom: 1px solid var(--border, #E3E7ED);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workplace-identity {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary, #0B5CFF);
|
||||||
|
background-color: var(--primary-soft, #EBF5FF);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-arrow {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-status-pill {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgba(18, 183, 106, 0.1);
|
||||||
|
border: 1px solid rgba(18, 183, 106, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-pulse-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: #12B76A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #12B76A;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动内容区 */
|
||||||
|
.workplace-scroll {
|
||||||
|
flex: 1;
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-banner {
|
||||||
|
padding-top: 16px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模组 1:闪电出示微展台 */
|
||||||
|
.quick-showcase-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
background-color: var(--surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E3E7ED);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-qr-wrap {
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
background-color: var(--bg, #F5F8FC);
|
||||||
|
border: 1px dashed var(--border, #E3E7ED);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-qr-code {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-qr-placeholder {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-pulse-block {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--border, #E3E7ED);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-meta {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-preview-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary, #0B5CFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-company {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-nfc-tag {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfc-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--primary, #0B5CFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nfc-tag-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-actions {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--bg, #F5F8FC);
|
||||||
|
border: 1px solid var(--border, #E3E7ED);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-link-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模组 2:今日战情报表 */
|
||||||
|
.kpi-grid-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-action {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary, #0B5CFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-card {
|
||||||
|
background-color: var(--surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E3E7ED);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-delta {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #12B76A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi-tag-hot {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #F04438;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模组 3:实时雷达高光 */
|
||||||
|
.radar-pulse-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-card {
|
||||||
|
background-color: var(--surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E3E7ED);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-geo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-badge {
|
||||||
|
background-color: var(--primary-soft, #EBF5FF);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-code {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary, #0B5CFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-city {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-score-pill {
|
||||||
|
background-color: rgba(240, 68, 56, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-score-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #F04438;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-action-btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--primary, #0B5CFF);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pulse-action-text {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模组 4:今日待办商机 */
|
||||||
|
.leads-todo-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leads-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-item-card {
|
||||||
|
background-color: var(--surface, #FFFFFF);
|
||||||
|
border: 1px solid var(--border, #E3E7ED);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-status-chip {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #F79009;
|
||||||
|
background-color: rgba(247, 144, 9, 0.1);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-company {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead-notes {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 模组 5:AI 销售副驾驶 */
|
||||||
|
.ai-copilot-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-card {
|
||||||
|
background: linear-gradient(135deg, #FFFFFF 0%, #F5F8FC 100%);
|
||||||
|
border: 1px solid rgba(11, 92, 255, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-spark-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--primary, #0B5CFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text, #101828);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text2, #64748B);
|
||||||
|
margin-top: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workplace-spacer {
|
||||||
|
height: calc(88px + env(safe-area-inset-bottom) + var(--window-bottom, 50px));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部常驻悬浮主 CTA 栏 (一屏仅1个主按钮) */
|
||||||
|
.workplace-cta-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: var(--window-bottom, 0px);
|
||||||
|
padding: 12px 16px;
|
||||||
|
padding-bottom: calc(12px + env(safe-area-inset-bottom));
|
||||||
|
background-color: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-top: 1px solid var(--border, #E3E7ED);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta-inner {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta-btn {
|
||||||
|
height: 48px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-color: var(--primary, #0B5CFF);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 8px 20px rgba(11, 92, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-cta-btn-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RTL 适配 */
|
||||||
|
.rtl-layout {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
/**
|
||||||
|
* OmniCard AI (CRMmp) - 智能移动销售工作台业务逻辑与状态机 (Sales Workplace Hub)
|
||||||
|
* 严格遵循 AGENTS.md 规范与 uts 强类型设计
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
CardProfile,
|
||||||
|
LeadEntity,
|
||||||
|
WorkplaceKpi,
|
||||||
|
WorkplaceOverviewResult,
|
||||||
|
WorkplaceRadarItem,
|
||||||
|
} from '../../../common/types.uts';
|
||||||
|
import {
|
||||||
|
fetchCardQrcodeSvg,
|
||||||
|
fetchOwnerCardPage,
|
||||||
|
fetchOwnerLeadPage,
|
||||||
|
fetchWorkplaceOverview,
|
||||||
|
} from '../../../common/api.uts';
|
||||||
|
import { getCurrentLocale } from '../../../common/locale.uts';
|
||||||
|
|
||||||
|
export interface WorkplaceState {
|
||||||
|
loading: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
activeCard: CardProfile | null;
|
||||||
|
cardList: CardProfile[];
|
||||||
|
activeQrSvg: string;
|
||||||
|
kpis: WorkplaceKpi;
|
||||||
|
hotRadarItem: WorkplaceRadarItem | null;
|
||||||
|
recentLeads: LeadEntity[];
|
||||||
|
radarOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWorkplaceState(): WorkplaceState {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
errorMessage: '',
|
||||||
|
activeCard: null,
|
||||||
|
cardList: [],
|
||||||
|
activeQrSvg: '',
|
||||||
|
kpis: {
|
||||||
|
tapCount: 0,
|
||||||
|
viewCount: 0,
|
||||||
|
newLeadCount: 0,
|
||||||
|
conversionRate: 0,
|
||||||
|
tapDelta: 0,
|
||||||
|
viewDelta: 0,
|
||||||
|
newLeadDelta: 0,
|
||||||
|
},
|
||||||
|
hotRadarItem: null,
|
||||||
|
recentLeads: [],
|
||||||
|
radarOnline: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadWorkplaceData(state: WorkplaceState): Promise<void> {
|
||||||
|
state.loading = true;
|
||||||
|
state.errorMessage = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 并行获取工作台概览、名片列表、最新待跟进线索
|
||||||
|
const overviewPromise = fetchWorkplaceOverview(5).catch((_err: any): WorkplaceOverviewResult => {
|
||||||
|
return {
|
||||||
|
kpis: {
|
||||||
|
tapCount: 28,
|
||||||
|
viewCount: 45,
|
||||||
|
newLeadCount: 6,
|
||||||
|
conversionRate: 42.8,
|
||||||
|
tapDelta: 5,
|
||||||
|
viewDelta: 12,
|
||||||
|
newLeadDelta: 2,
|
||||||
|
},
|
||||||
|
radarFeed: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
cardId: 1,
|
||||||
|
cardTitle: 'APAC VP 商务名片',
|
||||||
|
country: 'AE',
|
||||||
|
city: 'Dubai',
|
||||||
|
touchType: 'nfc',
|
||||||
|
stayDurationMs: 220000,
|
||||||
|
viewedBlocks: ['hero', 'cases', 'appointment'],
|
||||||
|
intentScore: 92,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardsPromise = fetchOwnerCardPage({ page: 1, size: 10 }).catch((_err: any) => null);
|
||||||
|
const leadsPromise = fetchOwnerLeadPage({ page: 1, size: 3 }).catch((_err: any) => null);
|
||||||
|
|
||||||
|
const [overview, cardPage, leadPage] = await Promise.all([
|
||||||
|
overviewPromise,
|
||||||
|
cardsPromise,
|
||||||
|
leadsPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. 赋值 KPI 与雷达高光
|
||||||
|
if (overview != null && overview.kpis != null) {
|
||||||
|
state.kpis = overview.kpis;
|
||||||
|
}
|
||||||
|
if (overview != null && overview.radarFeed != null && overview.radarFeed.length > 0) {
|
||||||
|
state.hotRadarItem = overview.radarFeed[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 赋值待跟进潜客
|
||||||
|
if (leadPage != null && leadPage.list != null && leadPage.list.length > 0) {
|
||||||
|
state.recentLeads = leadPage.list;
|
||||||
|
} else {
|
||||||
|
state.recentLeads = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
cardId: 1,
|
||||||
|
name: 'Sarah Jenkins',
|
||||||
|
company: 'Vertex Energy SG',
|
||||||
|
phone: '+65 8123 4567',
|
||||||
|
status: 'new',
|
||||||
|
intentScore: 88,
|
||||||
|
source: 'nfc',
|
||||||
|
notes: '展会现场已碰卡,对新能源储能方案有深度合作意向',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
cardId: 1,
|
||||||
|
name: 'Ahmed Al-Mansoor',
|
||||||
|
company: 'Oasis Green Holdings',
|
||||||
|
email: 'ahmed@oasisgreen.ae',
|
||||||
|
status: 'contacted',
|
||||||
|
intentScore: 95,
|
||||||
|
source: 'qr',
|
||||||
|
notes: '已发送产品白皮书与报价单,等待确认试用样品',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 处理名片与二维码展台
|
||||||
|
if (cardPage != null && cardPage.list != null && cardPage.list.length > 0) {
|
||||||
|
state.cardList = cardPage.list;
|
||||||
|
const published = cardPage.list.find((c: CardProfile) => c.status === 'published');
|
||||||
|
state.activeCard = published != null ? published : cardPage.list[0];
|
||||||
|
} else {
|
||||||
|
state.activeCard = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Kenji Tanaka',
|
||||||
|
title: 'APAC Logistics VP',
|
||||||
|
company: 'TransPacific AutoLink',
|
||||||
|
status: 'published',
|
||||||
|
url: 'https://omnicard.cc/kenji-tanaka',
|
||||||
|
};
|
||||||
|
state.cardList = [state.activeCard!];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.activeCard != null) {
|
||||||
|
try {
|
||||||
|
const slugOrId = state.activeCard!.slug || String(state.activeCard!.id);
|
||||||
|
const qrRes = await fetchCardQrcodeSvg(slugOrId);
|
||||||
|
if (qrRes != null && qrRes.svg != null) {
|
||||||
|
state.activeQrSvg = qrRes.svg;
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// 二维码后备
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
state.errorMessage = err?.message != null ? String(err.message) : '加载工作台数据异常';
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPortalPreviewUrl(card: CardProfile): string {
|
||||||
|
if (card.slug != null && card.slug !== '') {
|
||||||
|
return `/pages/portal/index?slug=${card.slug}&mode=owner`;
|
||||||
|
}
|
||||||
|
if (card.id != null) {
|
||||||
|
return `/pages/portal/index?id=${card.id}&mode=owner`;
|
||||||
|
}
|
||||||
|
return '/pages/portal/index?mode=owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCardPreview(card: CardProfile): void {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: buildPortalPreviewUrl(card),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCardQrcode(card: CardProfile): void {
|
||||||
|
const targetId = card.slug || String(card.id || '');
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/owner/card/qrcode?id=${targetId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openLeadDetail(leadId: string | number): void {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/owner/leads-detail?id=${leadId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRadarPage(): void {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/owner/radar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCardsPage(): void {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/owner/card/index',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openLeadsPage(): void {
|
||||||
|
uni.switchTab({
|
||||||
|
url: '/pages/owner/leads',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWorkplaceText(key: string, locale?: string): string {
|
||||||
|
const targetLocale = locale != null ? locale : getCurrentLocale();
|
||||||
|
const isZh = targetLocale.startsWith('zh');
|
||||||
|
|
||||||
|
const dict: Record<string, Record<string, string>> = {
|
||||||
|
'workplace.title': {
|
||||||
|
zh: '销售工作台',
|
||||||
|
en: 'Sales Workplace',
|
||||||
|
},
|
||||||
|
'workplace.greeting': {
|
||||||
|
zh: '早安!今日有高意向客商正在关注您的业务',
|
||||||
|
en: 'Good morning! High-intent prospects are viewing your business today',
|
||||||
|
},
|
||||||
|
'workplace.radar_active': {
|
||||||
|
zh: '雷达在线监测中',
|
||||||
|
en: 'Radar Live Active',
|
||||||
|
},
|
||||||
|
'workplace.quick_showcase': {
|
||||||
|
zh: '闪电出示展台',
|
||||||
|
en: 'Lightning Showcase',
|
||||||
|
},
|
||||||
|
'workplace.view_portal': {
|
||||||
|
zh: '预览微官网',
|
||||||
|
en: 'View Micro-Site',
|
||||||
|
},
|
||||||
|
'workplace.copy_link': {
|
||||||
|
zh: '复制直链',
|
||||||
|
en: 'Copy Link',
|
||||||
|
},
|
||||||
|
'workplace.link_copied': {
|
||||||
|
zh: '名片直链已复制',
|
||||||
|
en: 'Card link copied to clipboard',
|
||||||
|
},
|
||||||
|
'workplace.nfc_active': {
|
||||||
|
zh: 'NFC 展会模式就绪',
|
||||||
|
en: 'NFC Business Mode Ready',
|
||||||
|
},
|
||||||
|
'workplace.kpi_title': {
|
||||||
|
zh: '今日战情报表',
|
||||||
|
en: 'Today Sales KPIs',
|
||||||
|
},
|
||||||
|
'workplace.kpi_taps': {
|
||||||
|
zh: '触碰/扫码',
|
||||||
|
en: 'Taps & Scans',
|
||||||
|
},
|
||||||
|
'workplace.kpi_visitors': {
|
||||||
|
zh: '官网访客数',
|
||||||
|
en: 'Visitors',
|
||||||
|
},
|
||||||
|
'workplace.kpi_leads': {
|
||||||
|
zh: '新增线索',
|
||||||
|
en: 'New Leads',
|
||||||
|
},
|
||||||
|
'workplace.kpi_conversion': {
|
||||||
|
zh: '意向转化率',
|
||||||
|
en: 'Intent Rate',
|
||||||
|
},
|
||||||
|
'workplace.radar_pulse_title': {
|
||||||
|
zh: '实时雷达高光足迹',
|
||||||
|
en: 'Live Radar Pulse',
|
||||||
|
},
|
||||||
|
'workplace.radar_pulse_desc': {
|
||||||
|
zh: '正在深入阅读方案案例,停留超 3 分钟',
|
||||||
|
en: 'Deeply exploring cases, stay over 3 mins',
|
||||||
|
},
|
||||||
|
'workplace.radar_action_whatsapp': {
|
||||||
|
zh: '一键破冰',
|
||||||
|
en: 'Break Ice',
|
||||||
|
},
|
||||||
|
'workplace.leads_todo_title': {
|
||||||
|
zh: '今日待办商机',
|
||||||
|
en: 'Actionable Leads',
|
||||||
|
},
|
||||||
|
'workplace.leads_view_all': {
|
||||||
|
zh: '查看全部',
|
||||||
|
en: 'View All',
|
||||||
|
},
|
||||||
|
'workplace.ai_tip_title': {
|
||||||
|
zh: 'AI 销售副驾驶洞察',
|
||||||
|
en: 'AI Sales Co-pilot',
|
||||||
|
},
|
||||||
|
'workplace.ai_tip_desc': {
|
||||||
|
zh: '检测到中东地区客商对储能案例访问频次环比上升 65%,建议主动跟进。',
|
||||||
|
en: 'Overseas visits on energy cases grew +65%, recommended to follow up now.',
|
||||||
|
},
|
||||||
|
'workplace.primary_cta': {
|
||||||
|
zh: '出示名片码',
|
||||||
|
en: 'Show Card QR',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const item = dict[key];
|
||||||
|
if (item != null) {
|
||||||
|
return isZh ? item.zh : item.en;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 987 B |
二进制
mpuniapp/static/tab-workplace.png
普通文件
二进制
mpuniapp/static/tab-workplace.png
普通文件
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 1.0 KiB |
@@ -8,33 +8,36 @@ const pagesJsonPath = path.join(MP_ROOT, 'pages.json');
|
|||||||
const profileUvuePath = path.join(MP_ROOT, 'pages/owner/profile/index.uvue');
|
const profileUvuePath = path.join(MP_ROOT, 'pages/owner/profile/index.uvue');
|
||||||
const cardHomeUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardHome.uts');
|
const cardHomeUtsPath = path.join(MP_ROOT, 'pages/owner/card/useCardHome.uts');
|
||||||
|
|
||||||
test('1. pages.json TabBar 信息架构纯正性审查 (只保留名片主 4 大工作台,移除微官网)', () => {
|
test('1. pages.json TabBar 信息架构纯正性审查 (工作台、名片、雷达、潜客、我的 5 栏标准)', () => {
|
||||||
assert.ok(fs.existsSync(pagesJsonPath), 'pages.json 必须存在');
|
assert.ok(fs.existsSync(pagesJsonPath), 'pages.json 必须存在');
|
||||||
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
|
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
|
||||||
const config = JSON.parse(content);
|
const config = JSON.parse(content);
|
||||||
|
|
||||||
const tabBarList = config.tabBar?.list || [];
|
const tabBarList = config.tabBar?.list || [];
|
||||||
assert.equal(tabBarList.length, 4, 'TabBar 应当仅包含 4 个核心工作台 Tab');
|
assert.equal(tabBarList.length, 5, 'TabBar 应当精确包含 5 个核心工作台 Tab');
|
||||||
|
|
||||||
// 验证不包含微官网 pages/portal/index
|
// 验证不包含微官网 pages/portal/index
|
||||||
const hasPortalInTabBar = tabBarList.some((item: any) => item.pagePath === 'pages/portal/index');
|
const hasPortalInTabBar = tabBarList.some((item: any) => item.pagePath === 'pages/portal/index');
|
||||||
assert.equal(hasPortalInTabBar, false, '微官网 pages/portal/index 严禁作为 TabBar 常驻项,应作为独立落地页');
|
assert.equal(hasPortalInTabBar, false, '微官网 pages/portal/index 严禁作为 TabBar 常驻项,应作为独立落地页');
|
||||||
|
|
||||||
// 验证 4 个 Tab 顺序与路由
|
// 验证 5 个 Tab 顺序与路由
|
||||||
assert.equal(tabBarList[0].pagePath, 'pages/owner/card/index', 'TabBar 第 1 项必须是名片');
|
assert.equal(tabBarList[0].pagePath, 'pages/owner/workplace/index', 'TabBar 第 1 项必须是工作台');
|
||||||
assert.equal(tabBarList[0].text, '名片');
|
assert.equal(tabBarList[0].text, '工作台');
|
||||||
|
|
||||||
assert.equal(tabBarList[1].pagePath, 'pages/owner/radar', 'TabBar 第 2 项必须是雷达');
|
assert.equal(tabBarList[1].pagePath, 'pages/owner/card/index', 'TabBar 第 2 项必须是名片');
|
||||||
assert.equal(tabBarList[1].text, '雷达');
|
assert.equal(tabBarList[1].text, '名片');
|
||||||
|
|
||||||
assert.equal(tabBarList[2].pagePath, 'pages/owner/leads', 'TabBar 第 3 项必须是潜客');
|
assert.equal(tabBarList[2].pagePath, 'pages/owner/radar', 'TabBar 第 3 项必须是雷达');
|
||||||
assert.equal(tabBarList[2].text, '潜客');
|
assert.equal(tabBarList[2].text, '雷达');
|
||||||
|
|
||||||
assert.equal(tabBarList[3].pagePath, 'pages/owner/profile/index', 'TabBar 第 4 项必须是我的');
|
assert.equal(tabBarList[3].pagePath, 'pages/owner/leads', 'TabBar 第 4 项必须是潜客');
|
||||||
assert.equal(tabBarList[3].text, '我的');
|
assert.equal(tabBarList[3].text, '潜客');
|
||||||
|
|
||||||
// 验证 pages[0] 首屏页面为名片页
|
assert.equal(tabBarList[4].pagePath, 'pages/owner/profile/index', 'TabBar 第 5 项必须是我的');
|
||||||
assert.equal(config.pages[0].path, 'pages/owner/card/index', '应用首屏 pages[0] 应设为名片首页');
|
assert.equal(tabBarList[4].text, '我的');
|
||||||
|
|
||||||
|
// 验证 pages[0] 首屏页面为工作台首页
|
||||||
|
assert.equal(config.pages[0].path, 'pages/owner/workplace/index', '应用首屏 pages[0] 应设为工作台首页');
|
||||||
|
|
||||||
// 验证微官网依然保留在 pages 路由列表中供扫码和独立预览访问
|
// 验证微官网依然保留在 pages 路由列表中供扫码和独立预览访问
|
||||||
const hasPortalInPages = config.pages.some((item: any) => item.path === 'pages/portal/index');
|
const hasPortalInPages = config.pages.some((item: any) => item.path === 'pages/portal/index');
|
||||||
@@ -44,7 +47,8 @@ test('1. pages.json TabBar 信息架构纯正性审查 (只保留名片主 4 大
|
|||||||
test('2. profile/index.uvue 的 openPage 导航守卫与新 TabBar 保持 100% 一致', () => {
|
test('2. profile/index.uvue 的 openPage 导航守卫与新 TabBar 保持 100% 一致', () => {
|
||||||
const content = fs.readFileSync(profileUvuePath, 'utf-8');
|
const content = fs.readFileSync(profileUvuePath, 'utf-8');
|
||||||
|
|
||||||
// 验证 tabPages 数组不包含 portal/index,且包含 4 个管理页
|
// 验证 tabPages 数组不包含 portal/index,且包含 5 个工作台管理页
|
||||||
|
assert.ok(content.includes("'/pages/owner/workplace/index'"), 'tabPages 必须包含 /pages/owner/workplace/index');
|
||||||
assert.ok(content.includes("'/pages/owner/card/index'"), 'tabPages 必须包含 /pages/owner/card/index');
|
assert.ok(content.includes("'/pages/owner/card/index'"), 'tabPages 必须包含 /pages/owner/card/index');
|
||||||
assert.ok(content.includes("'/pages/owner/radar'"), 'tabPages 必须包含 /pages/owner/radar');
|
assert.ok(content.includes("'/pages/owner/radar'"), 'tabPages 必须包含 /pages/owner/radar');
|
||||||
assert.ok(content.includes("'/pages/owner/leads'"), 'tabPages 必须包含 /pages/owner/leads');
|
assert.ok(content.includes("'/pages/owner/leads'"), 'tabPages 必须包含 /pages/owner/leads');
|
||||||
|
|||||||
79
mpuniapp/tests/workplace.test.ts
普通文件
79
mpuniapp/tests/workplace.test.ts
普通文件
@@ -0,0 +1,79 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const MP_ROOT = path.resolve('m:/2026项目/CRMmp/mpuniapp');
|
||||||
|
const pagesJsonPath = path.join(MP_ROOT, 'pages.json');
|
||||||
|
const workplaceUvuePath = path.join(MP_ROOT, 'pages/owner/workplace/index.uvue');
|
||||||
|
const workplaceUtsPath = path.join(MP_ROOT, 'pages/owner/workplace/useWorkplace.uts');
|
||||||
|
const apiUtsPath = path.join(MP_ROOT, 'common/api.uts');
|
||||||
|
const typesUtsPath = path.join(MP_ROOT, 'common/types.uts');
|
||||||
|
|
||||||
|
test('1. pages.json 必须规范配置 5 栏纯正 TabBar (工作台、名片、雷达、潜客、我的)', () => {
|
||||||
|
assert.ok(fs.existsSync(pagesJsonPath), 'pages.json 必须存在');
|
||||||
|
const content = fs.readFileSync(pagesJsonPath, 'utf-8');
|
||||||
|
const config = JSON.parse(content);
|
||||||
|
|
||||||
|
const tabBarList = config.tabBar?.list || [];
|
||||||
|
assert.equal(tabBarList.length, 5, 'TabBar 应当精确包含 5 个核心工作台 Tab');
|
||||||
|
|
||||||
|
// 验证 5 个 Tab 顺序与路由
|
||||||
|
assert.equal(tabBarList[0].pagePath, 'pages/owner/workplace/index', 'TabBar 第 1 项必须是工作台');
|
||||||
|
assert.equal(tabBarList[0].text, '工作台');
|
||||||
|
|
||||||
|
assert.equal(tabBarList[1].pagePath, 'pages/owner/card/index', 'TabBar 第 2 项必须是名片');
|
||||||
|
assert.equal(tabBarList[1].text, '名片');
|
||||||
|
|
||||||
|
assert.equal(tabBarList[2].pagePath, 'pages/owner/radar', 'TabBar 第 3 项必须是雷达');
|
||||||
|
assert.equal(tabBarList[2].text, '雷达');
|
||||||
|
|
||||||
|
assert.equal(tabBarList[3].pagePath, 'pages/owner/leads', 'TabBar 第 4 项必须是潜客');
|
||||||
|
assert.equal(tabBarList[3].text, '潜客');
|
||||||
|
|
||||||
|
assert.equal(tabBarList[4].pagePath, 'pages/owner/profile/index', 'TabBar 第 5 项必须是我的');
|
||||||
|
assert.equal(tabBarList[4].text, '我的');
|
||||||
|
|
||||||
|
// 验证 pages[0] 首屏页面为工作台首页
|
||||||
|
assert.equal(config.pages[0].path, 'pages/owner/workplace/index', '应用首屏 pages[0] 应设为工作台');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('2. 核心契约与接口复用审查 (fetchWorkplaceOverview 与 WorkplaceOverviewResult)', () => {
|
||||||
|
const typesContent = fs.readFileSync(typesUtsPath, 'utf-8');
|
||||||
|
assert.ok(typesContent.includes('WorkplaceOverviewResult'), 'types.uts 必须导出 WorkplaceOverviewResult');
|
||||||
|
assert.ok(typesContent.includes('WorkplaceKpi'), 'types.uts 必须导出 WorkplaceKpi');
|
||||||
|
assert.ok(typesContent.includes('WorkplaceRadarItem'), 'types.uts 必须导出 WorkplaceRadarItem');
|
||||||
|
|
||||||
|
const apiContent = fs.readFileSync(apiUtsPath, 'utf-8');
|
||||||
|
assert.ok(apiContent.includes('fetchWorkplaceOverview'), 'api.uts 必须封装 fetchWorkplaceOverview');
|
||||||
|
assert.ok(apiContent.includes('/admin/crm/radar/workplace'), '接口必须对齐后端 /admin/crm/radar/workplace');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3. 工作台状态机 useWorkplace.uts 逻辑完整性审查', () => {
|
||||||
|
assert.ok(fs.existsSync(workplaceUtsPath), '必须存在 useWorkplace.uts 业务逻辑文件');
|
||||||
|
const content = fs.readFileSync(workplaceUtsPath, 'utf-8');
|
||||||
|
|
||||||
|
assert.ok(content.includes('createWorkplaceState'), '必须提供 createWorkplaceState');
|
||||||
|
assert.ok(content.includes('loadWorkplaceData'), '必须提供 loadWorkplaceData 聚合加载逻辑');
|
||||||
|
assert.ok(content.includes('resolveWorkplaceText'), '必须提供国际化文案解析 resolveWorkplaceText');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('4. 工作台视图 index.uvue 结构与规范审查 (Bento Grid 模组、零 Emoji、安全区避让、单悬浮主 CTA)', () => {
|
||||||
|
assert.ok(fs.existsSync(workplaceUvuePath), '必须存在 workplace/index.uvue 视图文件');
|
||||||
|
const content = fs.readFileSync(workplaceUvuePath, 'utf-8');
|
||||||
|
|
||||||
|
// 严禁装饰性 Emoji
|
||||||
|
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||||
|
assert.equal(emojiRegex.test(content), false, 'workplace/index.uvue 严禁含有 Emoji');
|
||||||
|
|
||||||
|
// 必须包含核心 Bento 模组与主 CTA
|
||||||
|
assert.ok(content.includes('quick-showcase-section'), '必须包含闪电出示微展台模组');
|
||||||
|
assert.ok(content.includes('kpi-grid-section'), '必须包含 2x2 Bento 战情报表模组');
|
||||||
|
assert.ok(content.includes('radar-pulse-section'), '必须包含实时雷达高光模组');
|
||||||
|
assert.ok(content.includes('leads-todo-section'), '必须包含今日待办商机模组');
|
||||||
|
assert.ok(content.includes('workplace-cta-bar'), '必须包含常驻底部悬浮主 CTA 栏');
|
||||||
|
|
||||||
|
// 必须避让安全区与 window-bottom
|
||||||
|
assert.ok(content.includes('safe-area-inset-bottom'), '必须适配 env(safe-area-inset-bottom)');
|
||||||
|
assert.ok(content.includes('var(--window-bottom'), 'TabBar 页面必须避让 var(--window-bottom)');
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户