fix(admin): 修复工作台宽屏居中偏右与面包屑错位问题并升级转化漏斗视觉流转
这个提交包含在:
@@ -281,6 +281,44 @@ function Workplace(): React.ReactElement {
|
||||
};
|
||||
}, [overview.radarFeed]);
|
||||
|
||||
const interactCount = useMemo(
|
||||
() => overview.radarFeed.filter((f) => f.stayDurationMs > 10000).length,
|
||||
[overview.radarFeed]
|
||||
);
|
||||
|
||||
const funnelMetrics = useMemo(() => {
|
||||
const tap = kpis.tapCount;
|
||||
const view = kpis.viewCount;
|
||||
const interact = interactCount;
|
||||
const lead = kpis.newLeadCount;
|
||||
|
||||
const interactRatio = view > 0 ? Math.min(100, Math.round((interact / view) * 100)) : 0;
|
||||
const leadRatio = view > 0 ? Math.min(100, Math.round((lead / view) * 100)) : 0;
|
||||
|
||||
return {
|
||||
tap: {
|
||||
value: tap,
|
||||
ratioText: '100%',
|
||||
barWidth: '100%',
|
||||
},
|
||||
view: {
|
||||
value: view,
|
||||
ratioText: tap > 0 ? `${Math.min(100, Math.round((view / tap) * 100))}%` : '100%',
|
||||
barWidth: `${Math.min(100, Math.max(15, tap > 0 ? (view / tap) * 100 : 80))}%`,
|
||||
},
|
||||
interact: {
|
||||
value: interact,
|
||||
ratioText: `${interactRatio}%`,
|
||||
barWidth: `${Math.min(100, Math.max(12, interactRatio))}%`,
|
||||
},
|
||||
lead: {
|
||||
value: lead,
|
||||
ratioText: `${leadRatio}%`,
|
||||
barWidth: `${Math.min(100, Math.max(10, leadRatio))}%`,
|
||||
},
|
||||
};
|
||||
}, [kpis.tapCount, kpis.viewCount, kpis.newLeadCount, interactCount]);
|
||||
|
||||
// 雷达客商列表(根据过滤器筛选)
|
||||
const filteredFeed = useMemo(() => {
|
||||
if (feedFilter === 'high_intent') {
|
||||
@@ -394,71 +432,62 @@ function Workplace(): React.ReactElement {
|
||||
>
|
||||
<div className={styles.funnelContainer}>
|
||||
<div className={styles.funnelStep}>
|
||||
<div className={styles.funnelLabel}>{t['workplace.funnel.tap']}</div>
|
||||
<div className={styles.funnelValue}>{kpis.tapCount}</div>
|
||||
<div className={styles.funnelHeader}>
|
||||
<span className={styles.funnelLabel}>{t['workplace.funnel.tap']}</span>
|
||||
<span className={styles.funnelRate}>{funnelMetrics.tap.ratioText}</span>
|
||||
</div>
|
||||
<div className={styles.funnelValue}>{funnelMetrics.tap.value}</div>
|
||||
<div className={styles.funnelBarWrapper}>
|
||||
<div className={styles.funnelBar} style={{ width: '100%' }} />
|
||||
<div className={styles.funnelBar} style={{ width: funnelMetrics.tap.barWidth }} />
|
||||
</div>
|
||||
<div className={styles.funnelMeta}>
|
||||
<span>Base</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.funnelStep}>
|
||||
<div className={styles.funnelLabel}>{t['workplace.funnel.view']}</div>
|
||||
<div className={styles.funnelValue}>{kpis.viewCount}</div>
|
||||
<div className={styles.funnelHeader}>
|
||||
<span className={styles.funnelLabel}>{t['workplace.funnel.view']}</span>
|
||||
<span className={styles.funnelRate}>{funnelMetrics.view.ratioText}</span>
|
||||
</div>
|
||||
<div className={styles.funnelValue}>{funnelMetrics.view.value}</div>
|
||||
<div className={styles.funnelBarWrapper}>
|
||||
<div
|
||||
className={styles.funnelBar}
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
Math.max(15, kpis.tapCount > 0 ? (kpis.viewCount / kpis.tapCount) * 100 : 80)
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.funnelBar} style={{ width: funnelMetrics.view.barWidth }} />
|
||||
</div>
|
||||
<div className={styles.funnelMeta}>
|
||||
<span>Taps → Views</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.funnelStep}>
|
||||
<div className={styles.funnelLabel}>{t['workplace.funnel.interact']}</div>
|
||||
<div className={styles.funnelValue}>
|
||||
{overview.radarFeed.filter((f) => f.stayDurationMs > 10000).length}
|
||||
<div className={styles.funnelHeader}>
|
||||
<span className={styles.funnelLabel}>{t['workplace.funnel.interact']}</span>
|
||||
<span className={styles.funnelRate}>{funnelMetrics.interact.ratioText}</span>
|
||||
</div>
|
||||
<div className={styles.funnelValue}>{funnelMetrics.interact.value}</div>
|
||||
<div className={styles.funnelBarWrapper}>
|
||||
<div
|
||||
className={styles.funnelBar}
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
15,
|
||||
kpis.viewCount > 0
|
||||
? (overview.radarFeed.filter((f) => f.stayDurationMs > 10000).length /
|
||||
kpis.viewCount) *
|
||||
100
|
||||
: 50
|
||||
)
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.funnelBar} style={{ width: funnelMetrics.interact.barWidth }} />
|
||||
</div>
|
||||
<div className={styles.funnelMeta}>
|
||||
<span>Views → Engaged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.funnelStep}>
|
||||
<div className={styles.funnelLabel}>{t['workplace.funnel.lead']}</div>
|
||||
<div className={styles.funnelValue}>{kpis.newLeadCount}</div>
|
||||
<div className={styles.funnelHeader}>
|
||||
<span className={styles.funnelLabel}>{t['workplace.funnel.lead']}</span>
|
||||
<span className={styles.funnelRate}>{funnelMetrics.lead.ratioText}</span>
|
||||
</div>
|
||||
<div className={styles.funnelValue}>{funnelMetrics.lead.value}</div>
|
||||
<div className={styles.funnelBarWrapper}>
|
||||
<div
|
||||
className={`${styles.funnelBar} ${styles.funnelBarLead}`}
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
100,
|
||||
Math.max(
|
||||
15,
|
||||
kpis.viewCount > 0 ? (kpis.newLeadCount / kpis.viewCount) * 100 : 25
|
||||
)
|
||||
)}%`,
|
||||
}}
|
||||
style={{ width: funnelMetrics.lead.barWidth }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.funnelMeta}>
|
||||
<span>Engaged → Leads</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
.page {
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
margin: 0;
|
||||
font-family: var(--omni-font-family);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -191,32 +190,46 @@
|
||||
.funnelStep {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.funnelHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--omni-space-4);
|
||||
}
|
||||
|
||||
.funnelLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--omni-color-text-2);
|
||||
margin-bottom: var(--omni-space-4);
|
||||
}
|
||||
|
||||
.funnelRate {
|
||||
font-size: 12px;
|
||||
color: var(--omni-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.funnelValue {
|
||||
font-size: 20px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--omni-color-text);
|
||||
margin-bottom: var(--omni-space-8);
|
||||
}
|
||||
|
||||
.funnelBarWrapper {
|
||||
height: 4px;
|
||||
background: var(--omni-color-surface);
|
||||
border-radius: 2px;
|
||||
height: 6px;
|
||||
background: rgba(11, 92, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.funnelBar {
|
||||
height: 100%;
|
||||
background: var(--omni-color-primary);
|
||||
border-radius: 2px;
|
||||
border-radius: 3px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
@@ -224,6 +237,15 @@
|
||||
background: var(--omni-color-success);
|
||||
}
|
||||
|
||||
.funnelMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: var(--omni-space-4);
|
||||
font-size: 11px;
|
||||
color: var(--omni-color-text-2);
|
||||
}
|
||||
|
||||
.radarCard {
|
||||
margin-top: var(--omni-space-16);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function read(relPath: string): string {
|
||||
return fs.readFileSync(path.join(adminRoot, relPath), 'utf-8');
|
||||
}
|
||||
|
||||
const EMOJI_RE =
|
||||
/[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
|
||||
test('1. Workplace 根容器必须全宽自然左对齐,严禁 margin: 0 auto 产生脱节白区', () => {
|
||||
const styles = read('src/pages/dashboard/workplace/style/index.module.less');
|
||||
|
||||
// 必须全宽
|
||||
assert.ok(styles.includes('width: 100%'), 'Workplace 根元素必须 width: 100%');
|
||||
|
||||
// 严禁在 .page 根容器使用 margin: 0 auto,防止在大屏产生 200px+ 的左侧留白与错位
|
||||
const pageRuleMatch = styles.match(/\.page\s*\{([^}]+)\}/);
|
||||
assert.ok(pageRuleMatch, '必须包含 .page 规则');
|
||||
const pageRule = pageRuleMatch[1];
|
||||
assert.ok(
|
||||
!pageRule.includes('margin: 0 auto') && !pageRule.includes('margin:0 auto'),
|
||||
'Workplace .page 严禁使用 margin: 0 auto 导致与面包屑脱节错位'
|
||||
);
|
||||
assert.ok(
|
||||
!pageRule.includes('max-width: 1200px'),
|
||||
'Workplace 作为复杂工作台仪表盘不应在宽屏下硬锁 1200px 造成两侧悬空'
|
||||
);
|
||||
});
|
||||
|
||||
test('2. 转化漏斗 (Conversion Funnel) 必须具有高对比度轨道底色、增厚条与流转连贯设计', () => {
|
||||
const styles = read('src/pages/dashboard/workplace/style/index.module.less');
|
||||
const tsx = read('src/pages/dashboard/workplace/index.tsx');
|
||||
|
||||
// 轨道背景不可仅为近乎隐形的 omni-color-surface,必须有明显底色(如 rgba(11, 92, 255, 0.08) 或边框)
|
||||
assert.ok(
|
||||
styles.includes('rgba(11, 92, 255') || styles.includes('--omni-color-primary-soft'),
|
||||
'漏斗条轨道槽必须具备清晰可见的对比度底色'
|
||||
);
|
||||
|
||||
// 漏斗条高度提升至至少 6px
|
||||
assert.ok(
|
||||
styles.includes('height: 6px') || styles.includes('height: 8px'),
|
||||
'漏斗条高度必须至少 6px 提升视觉分量'
|
||||
);
|
||||
|
||||
// 必须展示漏斗转化比率或流转指标
|
||||
assert.ok(
|
||||
tsx.includes('funnelRate') || tsx.includes('funnelRatio') || tsx.includes('funnelMeta'),
|
||||
'漏斗各步骤必须展示转化比率或流转说明'
|
||||
);
|
||||
});
|
||||
|
||||
test('3. 快捷导航卡片副标题必须具备单行文本防截断样式', () => {
|
||||
const styles = read('src/pages/dashboard/workplace/style/index.module.less');
|
||||
|
||||
assert.ok(
|
||||
styles.includes('text-overflow: ellipsis') || styles.includes('overflow: hidden'),
|
||||
'快捷卡片副标题必须具备溢出隐藏保护'
|
||||
);
|
||||
assert.ok(
|
||||
styles.includes('white-space: nowrap'),
|
||||
'快捷卡片副标题必须保持单行不发生难看的断字折行'
|
||||
);
|
||||
});
|
||||
|
||||
test('4. 零 any,零 emoji,符合出海国际化商务规范', () => {
|
||||
const tsx = read('src/pages/dashboard/workplace/index.tsx');
|
||||
const styles = read('src/pages/dashboard/workplace/style/index.module.less');
|
||||
|
||||
assert.ok(!/\bany\b/.test(tsx), 'Workplace 代码严禁使用 any');
|
||||
assert.ok(!EMOJI_RE.test(tsx), 'Workplace 代码严禁使用 emoji');
|
||||
assert.ok(!EMOJI_RE.test(styles), 'Workplace 样式严禁使用 emoji');
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户