You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
253 lines
12 KiB
253 lines
12 KiB
import { useState, type CSSProperties } from 'react';
|
|
import { getTranslation, type Locale } from '../i18n/utils';
|
|
import { Icon } from '../icons';
|
|
|
|
/* =====================================================================
|
|
* 数字生活生态全景图 — 首页 3 受众 × 业务页 5 赛道的「桥」
|
|
* 依据:business-architecture.md §4 产品×受众矩阵(2026-08 已拍板)
|
|
* 同一组 8 个产品,两种视角:按赛道(连接/成长/交易/理解/成全)/ 按受众(消费者/企业/未来)
|
|
* ===================================================================== */
|
|
|
|
// 已确认的产品×受众矩阵。顺序与 i18n business.tracks[].products[] 严格一致。
|
|
// C=消费者 B=企业 F=未来(平台层)
|
|
type Aud = { c: boolean; b: boolean; f: boolean };
|
|
const MATRIX: Aud[][] = [
|
|
// 内容社交:青核 / 音酷 / 蜜恋
|
|
[{ c: true, b: false, f: false }, { c: true, b: false, f: false }, { c: true, b: false, f: false }],
|
|
// 职场服务:轻松聘 / 智愿通
|
|
[{ c: true, b: true, f: false }, { c: true, b: true, f: false }],
|
|
// 数字支付:妥哒
|
|
[{ c: true, b: true, f: false }],
|
|
// 人工智能:阿布AI
|
|
[{ c: true, b: false, f: true }],
|
|
// 生活服务:听愿
|
|
[{ c: true, b: false, f: true }],
|
|
];
|
|
|
|
type Lens = 'track' | 'audience';
|
|
type Sel =
|
|
| { kind: 'center' }
|
|
| { kind: 'track'; i: number }
|
|
| { kind: 'aud'; key: 'c' | 'b' | 'f' };
|
|
|
|
// 5 赛道节点角度(五边形:从顶点顺时针每 72°)
|
|
const TRACK_ANGLES = [-90, -18, 54, 126, 198];
|
|
// 3 受众节点角度(顶 / 左下 / 右下)
|
|
const AUD_ANGLES = [-90, 150, 30];
|
|
|
|
function polar(angleDeg: number, r: number) {
|
|
const a = (angleDeg * Math.PI) / 180;
|
|
return { x: 50 + r * Math.cos(a), y: 50 + r * Math.sin(a) };
|
|
}
|
|
|
|
export default function EcosystemMap({ locale }: { locale: Locale }) {
|
|
const t = getTranslation(locale);
|
|
const tracks = t.business.tracks;
|
|
const isEn = locale === 'en';
|
|
const tr = (zh: string, hk: string, en: string) => (isEn ? en : locale === 'hk' ? hk : zh);
|
|
|
|
const [lens, setLens] = useState<Lens>('track');
|
|
const [sel, setSel] = useState<Sel>({ kind: 'track', i: 0 });
|
|
|
|
const audienceMeta: { key: 'c' | 'b' | 'f'; name: string; icon: string; def: string; angle: number }[] = [
|
|
{ key: 'c', name: tr('消费者', '消費者', 'Consumers'), icon: 'users', def: tr('个体的数字生活', '個體的數字生活', "Individuals' digital life"), angle: AUD_ANGLES[0] },
|
|
{ key: 'b', name: tr('企业', '企業', 'Enterprises'), icon: 'briefcase', def: tr('组织的效率与增长', '組織的效率與增長', 'Organizational efficiency & growth'), angle: AUD_ANGLES[1] },
|
|
{ key: 'f', name: tr('未来', '未來', 'Future'), icon: 'rocket', def: tr('前沿与平台层', '前沿與平台層', 'Frontier & platform layer'), angle: AUD_ANGLES[2] },
|
|
];
|
|
|
|
const actionLabel = (i: number) =>
|
|
tr(['连接', '成长', '交易', '理解', '成全'][i], ['連接', '成長', '交易', '理解', '成全'][i], ['Connect', 'Grow', 'Transact', 'Understand', 'Fulfill'][i]);
|
|
|
|
const totalProducts = tracks.reduce((n, tk) => n + tk.products.length, 0);
|
|
|
|
// 切换视角时回到该视角的默认选中
|
|
const switchLens = (next: Lens) => {
|
|
setLens(next);
|
|
setSel(next === 'track' ? { kind: 'track', i: 0 } : { kind: 'aud', key: 'c' });
|
|
};
|
|
|
|
// —— 右侧详情面板的产品列表 ——
|
|
type PItem = { name: string; desc: string; icon?: string; aud: Aud };
|
|
let panelTitle: string;
|
|
let panelEyebrow: string;
|
|
let panelNote: string;
|
|
let products: PItem[];
|
|
|
|
if (sel.kind === 'center') {
|
|
panelEyebrow = tr('数字生活生态', '數字生活生態', 'Digital Life Ecosystem');
|
|
panelTitle = tr('巴适伙伴', '巴適夥伴', 'BaskBuddy');
|
|
panelNote = tr(
|
|
`以"温暖科技"连接人、机会、价值与智能——${tracks.length} 大赛道 · ${totalProducts} 个自研产品,共建数字生活生态。`,
|
|
`以「溫暖科技」連接人、機會、價值與智能——${tracks.length} 大賽道 · ${totalProducts} 個自研產品,共建數字生活生態。`,
|
|
`Connecting people, opportunities, value & intelligence with "warm tech" — ${tracks.length} tracks · ${totalProducts} self-built products.`,
|
|
);
|
|
products = tracks.flatMap((tk, ti) =>
|
|
tk.products.map((p, pi) => ({ name: p.name, desc: p.desc, icon: p.icon, aud: MATRIX[ti][pi] })),
|
|
);
|
|
} else if (sel.kind === 'track') {
|
|
const ti = sel.i;
|
|
const tk = tracks[ti];
|
|
panelEyebrow = tr('赛道', '賽道', 'Track');
|
|
panelTitle = `${tk.name} · ${actionLabel(ti)}`;
|
|
panelNote = tr(
|
|
['人与人的连接', '人与机会的连接', '人与价值的连接', '人与智能的连接', '人与心愿的连接'][ti],
|
|
['人與人的連接', '人與機會的連接', '人與價值的連接', '人與智能的連接', '人與心願的連接'][ti],
|
|
['People ↔ people', 'People ↔ opportunities', 'People ↔ value', 'People ↔ intelligence', 'People ↔ wishes'][ti],
|
|
);
|
|
products = tk.products.map((p, pi) => ({ name: p.name, desc: p.desc, icon: p.icon, aud: MATRIX[ti][pi] }));
|
|
} else {
|
|
const key = sel.key;
|
|
const am = audienceMeta.find((a) => a.key === key)!;
|
|
panelEyebrow = tr('受众', '受眾', 'Audience');
|
|
panelTitle = am.name;
|
|
panelNote = am.def;
|
|
products = tracks.flatMap((tk, ti) =>
|
|
tk.products.flatMap((p, pi) =>
|
|
MATRIX[ti][pi][key]
|
|
? [{ name: p.name, desc: p.desc, icon: p.icon, aud: MATRIX[ti][pi] }]
|
|
: [],
|
|
),
|
|
);
|
|
}
|
|
|
|
const audDot = (a: Aud) => (
|
|
<span className="eco-aud-dots" aria-hidden="true">
|
|
{a.c && <i className="eco-dot eco-dot-c" title={tr('消费者', '消費者', 'Consumers')}>C</i>}
|
|
{a.b && <i className="eco-dot eco-dot-b" title={tr('企业', '企業', 'Enterprises')}>B</i>}
|
|
{a.f && <i className="eco-dot eco-dot-f" title={tr('未来', '未來', 'Future')}>F</i>}
|
|
</span>
|
|
);
|
|
|
|
return (
|
|
<div className="eco-shell">
|
|
{/* —— 视角切换 —— */}
|
|
<div className="eco-lens" role="tablist" aria-label={tr('全景图视角', '全景圖視角', 'Map view')}>
|
|
{(['track', 'audience'] as Lens[]).map((l) => (
|
|
<button
|
|
key={l}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={lens === l}
|
|
className={`eco-lens-btn${lens === l ? ' is-active' : ''}`}
|
|
onClick={() => switchLens(l)}
|
|
>
|
|
{l === 'track' ? tr('按赛道看', '按賽道看', 'By track') : tr('按受众看', '按受眾看', 'By audience')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="eco-body">
|
|
{/* —— 全景图舞台 —— */}
|
|
<div className="eco-stage" role="group" aria-label={tr('生态全景图', '生態全景圖', 'Ecosystem map')}>
|
|
{/* 连接线 */}
|
|
<svg className="eco-lines" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
|
{(lens === 'track' ? TRACK_ANGLES : AUD_ANGLES).map((ang, i) => {
|
|
const p = polar(ang, 36);
|
|
const on =
|
|
lens === 'track'
|
|
? sel.kind === 'track' && sel.i === i
|
|
: sel.kind === 'aud' && sel.key === audienceMeta[i].key;
|
|
return <line key={i} x1={50} y1={50} x2={p.x} y2={p.y} className={`eco-line${on ? ' is-active' : ''}`} />;
|
|
})}
|
|
</svg>
|
|
|
|
{/* 中心枢纽 */}
|
|
<button
|
|
type="button"
|
|
className={`eco-hub${sel.kind === 'center' ? ' is-active' : ''}`}
|
|
style={{ left: '50%', top: '50%' } as CSSProperties}
|
|
onClick={() => setSel({ kind: 'center' })}
|
|
aria-pressed={sel.kind === 'center'}
|
|
>
|
|
<span className="eco-hub-glow" aria-hidden="true" />
|
|
<span className="eco-hub-title">{tr('巴适伙伴', '巴適夥伴', 'BaskBuddy')}</span>
|
|
<span className="eco-hub-sub">{tr('数字生活生态', '數字生活生態', 'Digital Life')}</span>
|
|
</button>
|
|
|
|
{/* 赛道节点 */}
|
|
{lens === 'track' &&
|
|
tracks.map((tk, i) => {
|
|
const p = polar(TRACK_ANGLES[i], 36);
|
|
const on = sel.kind === 'track' && sel.i === i;
|
|
return (
|
|
<button
|
|
key={tk.name}
|
|
type="button"
|
|
className={`eco-node eco-float${on ? ' is-active' : ''}`}
|
|
style={{ left: `${p.x}%`, top: `${p.y}%`, '--dly': `${i * 0.6}s` } as CSSProperties}
|
|
onClick={() => setSel({ kind: 'track', i })}
|
|
aria-pressed={on}
|
|
>
|
|
<span className="eco-node-icon">
|
|
<Icon name={tk.icon} className="w-5 h-5" />
|
|
</span>
|
|
<span className="eco-node-name">{tk.name}</span>
|
|
<span className="eco-node-action">{actionLabel(i)}</span>
|
|
<span className="eco-node-count">{tk.products.length}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* 受众节点 */}
|
|
{lens === 'audience' &&
|
|
audienceMeta.map((am) => {
|
|
const p = polar(am.angle, 36);
|
|
const on = sel.kind === 'aud' && sel.key === am.key;
|
|
const count = tracks.reduce(
|
|
(n, tk, ti) => n + tk.products.filter((_, pi) => MATRIX[ti][pi][am.key]).length,
|
|
0,
|
|
);
|
|
return (
|
|
<button
|
|
key={am.key}
|
|
type="button"
|
|
className={`eco-node eco-float${on ? ' is-active' : ''}`}
|
|
style={{ left: `${p.x}%`, top: `${p.y}%`, '--dly': `${am.key === 'c' ? 0 : am.key === 'b' ? 0.6 : 1.2}s` } as CSSProperties}
|
|
onClick={() => setSel({ kind: 'aud', key: am.key })}
|
|
aria-pressed={on}
|
|
>
|
|
<span className="eco-node-icon">
|
|
<Icon name={am.icon} className="w-5 h-5" />
|
|
</span>
|
|
<span className="eco-node-name">{am.name}</span>
|
|
<span className="eco-node-count">{count}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* —— 详情面板 —— */}
|
|
<aside className="eco-panel" aria-live="polite">
|
|
<span className="section-eyebrow">{panelEyebrow}</span>
|
|
<h3 className="eco-panel-title">{panelTitle}</h3>
|
|
<p className="eco-panel-note">{panelNote}</p>
|
|
<ul className="eco-products">
|
|
{products.map((p) => (
|
|
<li key={p.name} className="eco-product">
|
|
<span className="eco-product-icon">
|
|
{p.icon && <Icon name={p.icon} className="w-[18px] h-[18px]" />}
|
|
</span>
|
|
<div className="eco-product-main">
|
|
<div className="eco-product-head">
|
|
<span className="eco-product-name">{p.name}</span>
|
|
{audDot(p.aud)}
|
|
</div>
|
|
<p className="eco-product-desc">{p.desc}</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</aside>
|
|
</div>
|
|
|
|
{/* 受众图例(桥接说明) */}
|
|
<p className="eco-legend">
|
|
{tr(
|
|
'同一组产品,两种视角:按"赛道"看我们造什么,按"受众"看我们为谁。',
|
|
'同一組產品,兩種視角:按「賽道」看我們造什麼,按「受眾」看我們為誰。',
|
|
'Same products, two lenses: "By track" shows what we build; "By audience" shows who we serve.',
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|