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

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

889 行
20 KiB
Plaintext

<template>
<view class="workplace-page" :class="pageRtlClass" :style="pageTheme">
<view class="workplace-topbar">
<view class="workplace-identity" @click="handleSwitchCard">
<text class="identity-name">{{ activeCardName }}</text>
<text class="identity-switch">{{ t('workplace.switch_card') }}</text>
</view>
<view class="workplace-status-row">
<text v-if="showPublished" class="identity-badge">{{ t('workplace.published') }}</text>
<view class="radar-status-pill" :class="{ 'radar-status-idle': !state.radarOnline }" @click="handleOpenRadar">
<view v-if="state.radarOnline" class="radar-pulse-dot"></view>
<text class="radar-status-text">{{ radarStatusText }}</text>
</view>
</view>
</view>
<scroll-view class="workplace-scroll" :scroll-y="true">
<view v-if="state.errorMessage !== ''" class="workplace-error" @click="handleReload">
<text class="workplace-error-text">{{ state.errorMessage }}</text>
</view>
<view v-if="state.loading" class="workplace-skeleton">
<view class="skeleton-block"></view>
<view class="skeleton-line"></view>
<view class="skeleton-line"></view>
</view>
<view v-else class="workplace-body">
<view class="quick-showcase-section">
<text class="section-title">{{ t('workplace.quick_showcase') }}</text>
<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>
<text v-else class="showcase-qr-empty">{{ t('workplace.qr_empty') }}</text>
</view>
<view class="showcase-meta">
<text class="meta-title">{{ activeCardTitle }}</text>
<text v-if="activeCardCompany !== ''" class="meta-company">{{ activeCardCompany }}</text>
<text class="meta-preview-btn" @click="handlePreviewPortal">{{ t('workplace.view_portal') }}</text>
<view v-if="state.activeShareUrl !== ''" class="copy-link-btn" @click="handleCopyLink">
<text class="copy-link-text">{{ t('workplace.copy_link') }}</text>
</view>
</view>
</view>
</view>
<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 v-if="state.recentLeads.length === 0" class="section-empty" @click="handleOpenLeads">
<text class="section-empty-text">{{ t('workplace.empty_leads') }}</text>
</view>
<view
v-for="lead in state.recentLeads"
:key="lead.id"
class="lead-item-card"
@click="handleOpenLead(lead.id)"
>
<view class="lead-top">
<text class="lead-name">{{ lead.name }}</text>
<text class="lead-status-chip" :class="'status-' + lead.status">{{ leadStatusLabel(lead.status) }}</text>
</view>
<text v-if="leadCompany(lead) !== ''" class="lead-company">{{ leadCompany(lead) }}</text>
<text v-if="leadNote(lead) !== ''" class="lead-notes" :lines="2">{{ leadNote(lead) }}</text>
</view>
</view>
<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 v-if="state.hotRadarItem != null" class="pulse-card" @click="handleOpenRadar">
<view class="pulse-header">
<text class="pulse-place">{{ pulsePlace }}</text>
<text v-if="pulseScore !== ''" class="pulse-score-text">{{ pulseScore }}</text>
</view>
<text class="pulse-desc">{{ pulseDetail }}</text>
<view class="pulse-footer">
<text class="pulse-time">{{ formatTime(state.hotRadarItem!.createTime) }}</text>
<view class="pulse-action-btn" @click.stop="handleQuickContact">
<text class="pulse-action-text">{{ t('workplace.radar_action_whatsapp') }}</text>
</view>
</view>
</view>
<view v-else class="section-empty" @click="handleOpenRadar">
<text class="section-empty-text">{{ t('workplace.empty_radar') }}</text>
</view>
</view>
<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 v-if="state.kpis.tapDelta > 0" 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 v-if="state.kpis.viewDelta > 0" 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 v-if="state.kpis.newLeadDelta > 0" 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">{{ conversionText }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="workplace-spacer"></view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import { getCurrentLocale, isRtl, OmniLocale } from '../../../common/locale.uts';
import { utsLightColors } from '../../../common/tokens.uts';
import { LeadEntity } from '../../../common/types.uts';
import {
createWorkplaceState,
loadWorkplaceData,
openCardPreview,
openCardQrcode,
openCardsPage,
openLeadDetail,
openLeadsPage,
openRadarPage,
resolveWorkplaceText,
WorkplaceState,
} from './useWorkplace.uts';
const state = reactive(createWorkplaceState());
const currentLang = computed((): OmniLocale => getCurrentLocale());
const pageRtlClass = computed((): string => (isRtl(currentLang.value) ? 'rtl-layout' : 'ltr-layout'));
const pageTheme = computed((): Record<string, string> => {
return {
'--primary': utsLightColors.primary,
'--bg': utsLightColors.bg,
'--surface': utsLightColors.surface,
'--text': utsLightColors.text,
'--text2': utsLightColors.text2,
'--border': utsLightColors.border,
'--primary-soft': utsLightColors.primarySoft,
'--error': utsLightColors.error,
};
});
function t(key: string): string {
return resolveWorkplaceText(key, currentLang.value);
}
const activeCardName = computed((): string => {
if (state.activeCard == null || state.activeCard!.name === '') {
return t('workplace.empty_card');
}
return state.activeCard!.name;
});
const activeCardTitle = computed((): string => {
if (state.activeCard == null || state.activeCard!.title == null) {
return '';
}
return state.activeCard!.title!;
});
const activeCardCompany = computed((): string => {
if (state.activeCard == null || state.activeCard!.company == null) {
return '';
}
return state.activeCard!.company!;
});
const showPublished = computed((): boolean => {
return state.activeCard != null && state.activeCard!.status === 'published';
});
const radarStatusText = computed((): string => {
if (state.radarOnline) {
return t('workplace.radar_active');
}
return t('workplace.radar_pulse_title');
});
const conversionText = computed((): string => {
return String(state.kpis.conversionRate) + '%';
});
const pulsePlace = computed((): string => {
if (state.hotRadarItem == null) {
return '';
}
const country = state.hotRadarItem!.country != null ? state.hotRadarItem!.country : '';
const city = state.hotRadarItem!.city != null ? state.hotRadarItem!.city : '';
if (city !== '' && country !== '') {
return city + ' · ' + country;
}
if (city !== '') {
return city;
}
return country;
});
const pulseScore = computed((): string => {
if (state.hotRadarItem == null || state.hotRadarItem!.intentScore == null) {
return '';
}
const score = state.hotRadarItem!.intentScore!;
if (score <= 0) {
return '';
}
return t('workplace.score') + ' ' + String(score);
});
const pulseDetail = computed((): string => {
if (state.hotRadarItem == null) {
return '';
}
return formatStay(state.hotRadarItem!.stayDurationMs);
});
function isZh(): boolean {
return currentLang.value.startsWith('zh');
}
function formatStay(ms: number): string {
if (ms >= 60000) {
const minutes = Math.floor(ms / 60000);
return isZh() ? String(minutes) + ' 分钟' : String(minutes) + ' min';
}
const seconds = Math.max(0, Math.floor(ms / 1000));
return String(seconds) + 's';
}
function formatTime(time: string | Date | undefined): string {
if (time == null) {
return '';
}
let ms = 0;
if (typeof time === 'string') {
const parsed = Date.parse(time);
if (!Number.isNaN(parsed)) {
ms = parsed;
}
} else {
ms = time.getTime();
}
if (ms <= 0) {
return '';
}
const diffMin = Math.floor(Math.max(0, Date.now() - ms) / 60000);
if (diffMin < 2) {
return isZh() ? '刚刚' : 'Just now';
}
if (diffMin < 60) {
return isZh() ? String(diffMin) + ' 分钟前' : String(diffMin) + 'm ago';
}
const diffHour = Math.floor(diffMin / 60);
if (diffHour < 24) {
return isZh() ? String(diffHour) + ' 小时前' : String(diffHour) + 'h ago';
}
const diffDay = Math.floor(diffHour / 24);
return isZh() ? String(diffDay) + ' 天前' : String(diffDay) + 'd ago';
}
function leadStatusLabel(status: string): string {
if (status === 'new') {
return isZh() ? '新线索' : 'New';
}
if (status === 'contacted') {
return isZh() ? '已联系' : 'Contacted';
}
if (status === 'hot') {
return isZh() ? '高意向' : 'High intent';
}
if (status === 'won') {
return isZh() ? '已成交' : 'Won';
}
if (status === 'archived') {
return isZh() ? '已归档' : 'Archived';
}
return status;
}
function leadCompany(lead: LeadEntity): string {
if (lead.company == null) {
return '';
}
return lead.company;
}
function leadNote(lead: LeadEntity): string {
if (lead.notes == null) {
return '';
}
return lead.notes;
}
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 {
openRadarPage();
}
function handleCopyLink(): void {
if (state.activeShareUrl === '') {
return;
}
const url = state.activeShareUrl;
uni.setClipboardData({
data: url,
success: () => {
uni.showToast({
title: t('workplace.link_copied'),
icon: 'none',
});
},
});
}
function handleReload(): void {
loadWorkplaceData(state);
}
onShow(() => {
loadWorkplaceData(state);
});
</script>
<style scoped>
.workplace-page {
display: flex;
flex-direction: column;
width: 100vw;
max-width: 480px;
height: 100vh;
margin-inline: auto;
background-color: var(--bg, #F5F8FC);
color: var(--text, #101828);
overflow: hidden;
}
.rtl-layout {
direction: rtl;
}
.workplace-topbar {
flex-shrink: 0;
display: flex;
flex-direction: column;
padding-block-start: calc(8px + env(safe-area-inset-top, 0px));
padding-inline: 16px;
padding-block-end: 8px;
background-color: #F5F8FC;
}
.workplace-identity {
min-height: 44px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.workplace-status-row {
display: flex;
flex-direction: row;
align-items: center;
margin-block-end: 4px;
}
.workplace-identity:active {
opacity: 0.72;
}
.identity-name {
flex: 1;
min-width: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.25;
color: #101828;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identity-badge {
flex-shrink: 0;
margin-inline: 8px;
padding: 2px 8px;
border-radius: 9999px;
background-color: #EBF5FF;
font-size: 12px;
font-weight: 600;
color: #0B5CFF;
}
.identity-switch {
flex-shrink: 0;
min-height: 44px;
padding-inline: 4px;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #0B5CFF;
}
.radar-status-pill {
flex-shrink: 0;
min-height: 44px;
padding-inline: 10px;
display: flex;
flex-direction: row;
align-items: center;
border-radius: 9999px;
background-color: #ECFDF3;
}
.radar-status-idle {
background-color: #F7F8FA;
}
.radar-status-idle .radar-status-text {
color: #64748B;
}
.radar-status-pill:active {
opacity: 0.72;
}
.radar-pulse-dot {
width: 8px;
height: 8px;
margin-inline-end: 6px;
border-radius: 4px;
background-color: #12B76A;
}
.radar-status-text {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: #027A48;
}
.workplace-scroll {
flex: 1;
width: 100%;
height: 0;
min-height: 0;
}
.workplace-body {
width: 100%;
box-sizing: border-box;
padding-inline: 16px;
}
.workplace-error {
margin: 0 16px 8px;
padding: 12px;
border-radius: 12px;
background-color: #FEF3F2;
}
.workplace-error-text {
font-size: 14px;
line-height: 1.5;
color: #B42318;
}
.workplace-skeleton {
padding: 8px 16px;
}
.skeleton-block {
height: 112px;
margin-block-end: 12px;
border-radius: 16px;
background-color: #E3E7ED;
}
.skeleton-line {
height: 72px;
margin-block-end: 12px;
border-radius: 16px;
background-color: #E3E7ED;
}
.section-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 44px;
}
.section-title {
font-size: 16px;
font-weight: 700;
line-height: 1.25;
color: #101828;
}
.section-action {
min-height: 44px;
padding-inline: 4px;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #0B5CFF;
}
.section-empty {
min-height: 44px;
margin-block-end: 8px;
padding: 14px;
display: flex;
align-items: center;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.section-empty:active {
background-color: #F7F8FA;
}
.section-empty-text {
font-size: 14px;
line-height: 1.5;
color: #64748B;
}
.quick-showcase-section {
margin-block-end: 8px;
}
.showcase-card {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 4px;
padding: 12px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.showcase-qr-wrap {
width: 104px;
height: 104px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: #FFFFFF;
}
.showcase-qr-wrap:active {
opacity: 0.8;
}
.showcase-qr-code {
width: 104px;
height: 104px;
}
.showcase-qr-empty {
font-size: 12px;
line-height: 1.4;
color: #64748B;
text-align: center;
}
.showcase-meta {
flex: 1;
min-width: 0;
margin-inline-start: 12px;
display: flex;
flex-direction: column;
}
.meta-title {
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #101828;
}
.meta-company {
margin-top: 2px;
font-size: 13px;
line-height: 1.4;
color: #64748B;
}
.meta-preview-btn {
min-height: 44px;
display: flex;
align-items: center;
font-size: 14px;
font-weight: 600;
color: #0B5CFF;
}
.copy-link-btn {
align-self: flex-start;
min-height: 44px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: #EBF5FF;
border-radius: 9999px;
}
.copy-link-btn:active {
opacity: 0.72;
}
.copy-link-text {
font-size: 13px;
font-weight: 700;
color: #0B5CFF;
}
.leads-todo-section,
.radar-pulse-section,
.kpi-grid-section {
margin-block-start: 8px;
}
.lead-item-card {
margin-block-end: 8px;
padding: 12px 14px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.lead-item-card:active {
background-color: #F7F8FA;
}
.lead-top {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.lead-name {
flex: 1;
min-width: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #101828;
}
.lead-status-chip {
flex-shrink: 0;
margin-inline-start: 8px;
padding: 2px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
}
.status-new,
.status-hot {
color: #B42318;
background-color: #FEF3F2;
}
.status-contacted {
color: #B54708;
background-color: #FFFAEB;
}
.status-won {
color: #027A48;
background-color: #ECFDF3;
}
.status-archived {
color: #64748B;
background-color: #F7F8FA;
}
.lead-company {
margin-top: 4px;
font-size: 14px;
line-height: 1.4;
color: #101828;
}
.lead-notes {
margin-top: 2px;
max-height: 39px;
font-size: 13px;
line-height: 1.5;
color: #64748B;
overflow: hidden;
text-overflow: ellipsis;
}
.pulse-card {
margin-block-end: 8px;
padding: 14px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.pulse-card:active {
background-color: #F7F8FA;
}
.pulse-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.pulse-place {
flex: 1;
min-width: 0;
font-size: 16px;
font-weight: 700;
line-height: 1.3;
color: #101828;
}
.pulse-score-text {
flex-shrink: 0;
margin-inline-start: 8px;
font-size: 12px;
font-weight: 700;
color: #B42318;
}
.pulse-desc {
margin-top: 4px;
font-size: 14px;
line-height: 1.5;
color: #64748B;
}
.pulse-footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.pulse-time {
font-size: 12px;
color: #64748B;
}
.pulse-action-btn {
min-height: 44px;
padding-inline: 14px;
display: flex;
align-items: center;
justify-content: center;
background-color: #0B5CFF;
border-radius: 9999px;
}
.pulse-action-btn:active {
opacity: 0.88;
}
.pulse-action-text {
font-size: 14px;
font-weight: 600;
color: #FFFFFF;
}
.kpi-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.kpi-card {
width: 48%;
box-sizing: border-box;
margin-block-end: 8px;
padding: 12px;
background-color: #FFFFFF;
border: 1px solid #E3E7ED;
border-radius: 16px;
}
.kpi-card:active {
opacity: 0.8;
}
.kpi-label {
font-size: 12px;
line-height: 1.4;
color: #64748B;
}
.kpi-value-row {
display: flex;
flex-direction: row;
align-items: baseline;
margin-top: 4px;
}
.kpi-value {
font-size: 22px;
font-weight: 700;
line-height: 1.2;
color: #101828;
}
.kpi-delta {
margin-inline-start: 6px;
font-size: 12px;
font-weight: 600;
color: #027A48;
}
.workplace-spacer {
height: calc(16px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
</style>