All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m28s
管理端包裹/SKU 上传携带 customerId;服务渠道分组列表解析;9610 操作列渲染;Nana FAB 去掉数字角标并恢复顶栏消息。 Co-authored-by: Cursor <cursoragent@cursor.com>
2693 lines
101 KiB
JavaScript
2693 lines
101 KiB
JavaScript
/**
|
||
* IDE Command Center — logistics advisor (mock scripts + cards)
|
||
*/
|
||
(function () {
|
||
'use strict';
|
||
|
||
const STORAGE_THREADS = 'ide-ai-threads';
|
||
const STORAGE_ACTIVE_THREAD = 'ide-ai-active-thread';
|
||
const STORAGE_SESSION = 'ide-ai-session';
|
||
const PHONE = '18123959245';
|
||
const EMAIL = 'bd01@1st56.com';
|
||
const WECHAT = 'ydgjzzh';
|
||
// 企业微信管理后台 → 微信客服 → 在微信外接入 → 复制客服链接(勿带尾部空格)
|
||
const WECOM_KF_URL = 'https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a';
|
||
const ICP = '粤ICP备2025463970号-2';
|
||
const ADDRESS_ZH = '深圳宝安区福永怀德翠岗5区36栋';
|
||
const ADDRESS_EN = 'Bldg 36, Cuigang 5, Fuyong, Bao’an, Shenzhen';
|
||
|
||
const HERO_STATS = [
|
||
{
|
||
id: 'coverage',
|
||
interactive: true,
|
||
value: { zh: '30+', en: '30+' },
|
||
label: { zh: '覆盖国家/地区', en: 'Countries & regions' },
|
||
},
|
||
{
|
||
value: { zh: '8,000+', en: '8,000+' },
|
||
label: { zh: '服务跨境卖家', en: 'SMB sellers served' },
|
||
},
|
||
{
|
||
value: { zh: '1,200万+', en: '12M+' },
|
||
label: { zh: '累计运送包裹', en: 'Parcels shipped' },
|
||
},
|
||
];
|
||
|
||
const COPY = {
|
||
en: {
|
||
welcomeTitle: 'IDE, your cross-border logistics advisor',
|
||
welcomeTitleShort: 'IDE · Your logistics advisor',
|
||
welcomeSubLead: 'Preferential rates · proactive service · faster transit · ',
|
||
welcomeSubAccent: 'AI logistics',
|
||
welcomeSubTail: ' · dedicated lines, packets, air & DHL/UPS/FedEX/3PE express covered.',
|
||
placeholder: 'Ask about lanes, tracking, or 9610…',
|
||
sendLabel: 'Ask',
|
||
human: 'Talk to a human advisor',
|
||
you: 'You',
|
||
historyTitle: 'Chat history',
|
||
newChat: 'New chat',
|
||
newChatHint: 'New chat started — ask here',
|
||
emptyHistory: 'No conversations yet',
|
||
fallback: 'Quotes are in the customer system after you register. You can also talk to an advisor.',
|
||
trustPhone: PHONE,
|
||
trustAddress: ADDRESS_EN,
|
||
trustWechat: 'WeChat support',
|
||
trustEmail: EMAIL,
|
||
trustIcp: ICP,
|
||
guide9610Title: 'Ship first, tax second',
|
||
guide9610Kicker: 'Services',
|
||
guide9610Sub: 'Pick a lane. Tax and filings come after the parcel can leave.',
|
||
loginTrack: 'Sign in to track',
|
||
registerNow: 'Register to quote',
|
||
loginExisting: 'Already registered? Sign in',
|
||
heroCta: 'Ship now',
|
||
statsAria: 'Service highlights',
|
||
statsCoverageHint: 'Tap to view',
|
||
mapZoomHint: 'Tap a country to pin it on the map',
|
||
composeLead: 'Ask about a lane, or pick a shortcut below',
|
||
partnerTag: 'Partners',
|
||
partnerAria: 'Connected platforms and carriers',
|
||
routesTitle: 'Shipping lanes',
|
||
routesKicker: 'Routes',
|
||
routesSub: 'Compare transit time and trade terms, then ask for a quote.',
|
||
routesSearchPh: 'Search by product name…',
|
||
routesAll: 'All',
|
||
routesAsk: 'Ask advisor',
|
||
routesRegister: 'Register to quote',
|
||
routesSticky: 'Not sure which lane? Ask an advisor by destination',
|
||
routesEmpty: 'No lanes match right now',
|
||
routesRetry: 'Retry',
|
||
routesLoading: 'Loading lanes…',
|
||
routesDays: 'days',
|
||
routesAskEta: 'Ask advisor',
|
||
routesDdpHint: 'Duties prepaid',
|
||
routesDduHint: 'Duties unpaid',
|
||
routesExpand: 'More',
|
||
routesCollapse: 'Less',
|
||
},
|
||
zh: {
|
||
welcomeTitle: 'IDE,您身边的跨境物流顾问',
|
||
welcomeTitleShort: 'IDE · 跨境物流顾问',
|
||
welcomeSubLead: '价格更优惠 · 服务更主动 · 时效更快 · ',
|
||
welcomeSubAccent: 'AI 物流',
|
||
welcomeSubTail: ' · 专线/小包/空派/DHL/UPS/FedEX/3PE快递全覆盖。',
|
||
placeholder: '问线路、查轨迹、9610 政策…',
|
||
sendLabel: '提问',
|
||
human: '联系在线顾问',
|
||
you: '你',
|
||
historyTitle: '历史记录',
|
||
newChat: '新会话',
|
||
newChatHint: '已开启新会话,在这里提问',
|
||
emptyHistory: '暂无会话',
|
||
fallback: '官网不提供询价。完成注册登记后,即可在客户系统里看明码价。也可以先联系顾问。',
|
||
trustPhone: PHONE,
|
||
trustAddress: ADDRESS_ZH,
|
||
trustWechat: '微信客服',
|
||
trustEmail: EMAIL,
|
||
trustIcp: ICP,
|
||
guide9610Title: '先发货,再办税',
|
||
guide9610Kicker: '服务',
|
||
guide9610Sub: '先选怎么走货。税务和备案跟在后面,点一张卡就能问。',
|
||
loginTrack: '登录查真实轨迹',
|
||
registerNow: '去注册登记',
|
||
loginExisting: '已有账号,去登录',
|
||
heroCta: '立即发货',
|
||
statsAria: '服务数据',
|
||
statsCoverageHint: '点我查看',
|
||
mapZoomHint: '点击国家,地图上会用定位图标标出',
|
||
composeLead: '先问线路,或选快捷入口',
|
||
partnerTag: '合作伙伴',
|
||
partnerAria: '对接平台与服务商',
|
||
routesTitle: '线路',
|
||
routesKicker: 'Routes',
|
||
routesSub: '对比时效与贸易条款,选好运再询价',
|
||
routesSearchPh: '搜索产品名称…',
|
||
routesAll: '全部',
|
||
routesAsk: '问顾问报价',
|
||
routesRegister: '注册询价',
|
||
routesSticky: '不确定选哪条?让顾问按目的国推荐',
|
||
routesEmpty: '暂无匹配线路',
|
||
routesRetry: '重试',
|
||
routesLoading: '线路加载中…',
|
||
routesDays: '天',
|
||
routesAskEta: '询顾问',
|
||
routesDdpHint: '包税到门',
|
||
routesDduHint: '关税自理',
|
||
routesExpand: '展开',
|
||
routesCollapse: '收起',
|
||
},
|
||
};
|
||
|
||
const GUIDE_SERVICES = [
|
||
{
|
||
icon: 'lane',
|
||
tag: { en: 'Ship', zh: '发货' },
|
||
en: { title: 'US / EU / SEA parcels', body: 'US, Mexico, Canada, EU 27, the UK, Australia, New Zealand, and Southeast Asia parcels. Tracked end to end.' },
|
||
zh: { title: '欧美 / 东南亚专线', body: '美国、墨西哥、加拿大、欧盟 27 国及英国、澳大利亚、新西兰,以及东南亚小包。全程可追踪。' },
|
||
ask: { en: 'I need a line to the US or Southeast Asia', zh: '我要走欧美或东南亚专线' },
|
||
intent: 'quote',
|
||
},
|
||
{
|
||
icon: 'warehouse',
|
||
tag: { en: 'Warehouse', zh: '仓储' },
|
||
en: { title: 'Warehouse dropship', body: 'Inbound, pick and pack, ship when the order lands. SMB sellers do not need their own warehouse.' },
|
||
zh: { title: '仓储一件代发', body: '入仓、拣货打包,订单来了就发出去。中小卖家不用自己养仓。' },
|
||
ask: { en: 'I need warehouse dropshipping', zh: '我要仓储一件代发' },
|
||
intent: 'warehouse',
|
||
},
|
||
{
|
||
icon: 'clearance',
|
||
tag: { en: 'Ship', zh: '发货' },
|
||
en: { title: '9610 clearance', body: 'List release, consolidated declaration, and three-document match — so this parcel can actually leave.' },
|
||
zh: { title: '9610 报关出货', body: '清单核放、汇总申报、三单对碰。先保证这票货出得去。' },
|
||
ask: { en: 'I need to ship a 9610 parcel', zh: '我有一票 9610 要出货' },
|
||
intent: 'hard',
|
||
},
|
||
{
|
||
icon: 'advise',
|
||
tag: { en: 'Ship', zh: '发货' },
|
||
en: { title: 'Tough cargo', body: 'Battery, oversize, no invoice, or declined elsewhere. We pick the lane and the tax path first.' },
|
||
zh: { title: '疑难货怎么发', body: '带电、超长、无票、别人不接的单。先帮你选线路和税务路径。' },
|
||
ask: { en: 'Others declined this cargo. Can you take it?', zh: '别人不接的货,你们能发吗?' },
|
||
intent: 'hard',
|
||
},
|
||
{
|
||
icon: 'refund',
|
||
tag: { en: 'Tax', zh: '税务' },
|
||
en: { title: 'Export VAT refund', body: 'If you have special VAT invoices: we assemble the refund pack and file it on the e-tax bureau.' },
|
||
zh: { title: '出口退税', body: '有增值税专票:整理退税资料,并在电子税务局申报。进项税按退税率退还。' },
|
||
ask: { en: 'Can you handle export tax refund?', zh: '出口退税可以代办吗?' },
|
||
intent: 'hard',
|
||
},
|
||
{
|
||
icon: 'exempt',
|
||
tag: { en: 'Tax', zh: '税务' },
|
||
en: { title: 'Exemption without invoice', body: 'No special invoice: pilot-zone filing and exemption registration, so export VAT is waived.' },
|
||
zh: { title: '无票免征', body: '没有专票:综试区备案和免税登记。出口环节增值税免掉。' },
|
||
ask: { en: 'Help me register for VAT-free export', zh: '帮我办无票免征登记' },
|
||
intent: 'hard',
|
||
},
|
||
{
|
||
icon: 'filing',
|
||
tag: { en: 'Filing', zh: '备案' },
|
||
en: { title: 'Customs credentials', body: 'Importer/exporter filing, cross-border seller status, and e-port card — when volume is ready to scale.' },
|
||
zh: { title: '海关资质', body: '进出口收发货人、跨境电商资质、电子口岸卡。量起来再办也不迟。' },
|
||
ask: { en: 'I need customs credentials filed', zh: '我要办海关资质备案' },
|
||
intent: 'hard',
|
||
},
|
||
];
|
||
|
||
const MAP_COORDS = {
|
||
eu: [10.5, 50.5],
|
||
us: [-98.5, 39.8],
|
||
mx: [-102.5, 23.6],
|
||
ca: [-106.3, 56.1],
|
||
au: [134.5, -25.7],
|
||
nz: [174.8, -41.3],
|
||
de: [10.45, 51.16],
|
||
fr: [2.35, 46.85],
|
||
it: [12.5, 42.85],
|
||
es: [-3.7, 40.4],
|
||
nl: [5.3, 52.15],
|
||
be: [4.35, 50.85],
|
||
at: [14.55, 47.52],
|
||
ie: [-7.7, 53.35],
|
||
gb: [-2.5, 54.0],
|
||
pt: [-8.6, 39.4],
|
||
gr: [23.73, 37.98],
|
||
se: [18.07, 59.33],
|
||
dk: [12.57, 55.68],
|
||
fi: [24.94, 60.17],
|
||
pl: [21.01, 52.23],
|
||
cz: [14.44, 50.08],
|
||
hu: [19.05, 47.5],
|
||
ro: [24.97, 45.94],
|
||
bg: [23.32, 42.7],
|
||
sk: [17.11, 48.15],
|
||
si: [14.51, 46.05],
|
||
hr: [15.98, 45.81],
|
||
lt: [25.28, 54.69],
|
||
lv: [24.11, 56.95],
|
||
ee: [24.75, 59.44],
|
||
lu: [6.13, 49.61],
|
||
mt: [14.51, 35.9],
|
||
cy: [33.38, 35.18],
|
||
my: [101.98, 4.21],
|
||
sg: [103.82, 1.35],
|
||
ph: [121.77, 12.88],
|
||
th: [100.5, 13.75],
|
||
vn: [105.85, 21.03],
|
||
id: [113.92, -0.79],
|
||
};
|
||
|
||
const COVER_REGIONS = [
|
||
{ id: 'eu', dest: { zh: '欧盟', en: 'European Union' }, label: { zh: '欧盟 27 国 + 英国', en: 'EU 27 + UK' }, expand: true },
|
||
{ id: 'us', dest: { zh: '美国', en: 'United States' }, label: { zh: '美国', en: 'United States' } },
|
||
{ id: 'mx', dest: { zh: '墨西哥', en: 'Mexico' }, label: { zh: '墨西哥', en: 'Mexico' } },
|
||
{ id: 'ca', dest: { zh: '加拿大', en: 'Canada' }, label: { zh: '加拿大', en: 'Canada' } },
|
||
{ id: 'au', dest: { zh: '澳大利亚', en: 'Australia' }, label: { zh: '澳大利亚', en: 'Australia' } },
|
||
{ id: 'nz', dest: { zh: '新西兰', en: 'New Zealand' }, label: { zh: '新西兰', en: 'New Zealand' } },
|
||
];
|
||
|
||
const COVER_EU = [
|
||
{ mapId: 'de', zh: '德国', en: 'Germany' },
|
||
{ mapId: 'fr', zh: '法国', en: 'France' },
|
||
{ mapId: 'it', zh: '意大利', en: 'Italy' },
|
||
{ mapId: 'es', zh: '西班牙', en: 'Spain' },
|
||
{ mapId: 'nl', zh: '荷兰', en: 'Netherlands' },
|
||
{ mapId: 'be', zh: '比利时', en: 'Belgium' },
|
||
{ mapId: 'at', zh: '奥地利', en: 'Austria' },
|
||
{ mapId: 'ie', zh: '爱尔兰', en: 'Ireland' },
|
||
{ mapId: 'gb', zh: '英国', en: 'United Kingdom' },
|
||
{ mapId: 'pt', zh: '葡萄牙', en: 'Portugal' },
|
||
{ mapId: 'gr', zh: '希腊', en: 'Greece' },
|
||
{ mapId: 'se', zh: '瑞典', en: 'Sweden' },
|
||
{ mapId: 'dk', zh: '丹麦', en: 'Denmark' },
|
||
{ mapId: 'fi', zh: '芬兰', en: 'Finland' },
|
||
{ mapId: 'pl', zh: '波兰', en: 'Poland' },
|
||
{ mapId: 'cz', zh: '捷克', en: 'Czechia' },
|
||
{ mapId: 'hu', zh: '匈牙利', en: 'Hungary' },
|
||
{ mapId: 'ro', zh: '罗马尼亚', en: 'Romania' },
|
||
{ mapId: 'bg', zh: '保加利亚', en: 'Bulgaria' },
|
||
{ mapId: 'sk', zh: '斯洛伐克', en: 'Slovakia' },
|
||
{ mapId: 'si', zh: '斯洛文尼亚', en: 'Slovenia' },
|
||
{ mapId: 'hr', zh: '克罗地亚', en: 'Croatia' },
|
||
{ mapId: 'lt', zh: '立陶宛', en: 'Lithuania' },
|
||
{ mapId: 'lv', zh: '拉脱维亚', en: 'Latvia' },
|
||
{ mapId: 'ee', zh: '爱沙尼亚', en: 'Estonia' },
|
||
{ mapId: 'lu', zh: '卢森堡', en: 'Luxembourg' },
|
||
{ mapId: 'mt', zh: '马耳他', en: 'Malta' },
|
||
{ mapId: 'cy', zh: '塞浦路斯', en: 'Cyprus' },
|
||
];
|
||
|
||
const COVER_SEA = [
|
||
{ mapId: 'my', zh: '马来西亚', en: 'Malaysia' },
|
||
{ mapId: 'sg', zh: '新加坡', en: 'Singapore' },
|
||
{ mapId: 'ph', zh: '菲律宾', en: 'Philippines' },
|
||
{ mapId: 'th', zh: '泰国', en: 'Thailand' },
|
||
{ mapId: 'vn', zh: '越南', en: 'Vietnam' },
|
||
{ mapId: 'id', zh: '印度尼西亚', en: 'Indonesia' },
|
||
];
|
||
|
||
const INTENTS = [
|
||
{
|
||
id: 'quote',
|
||
icon: 'quote',
|
||
en: {
|
||
title: 'Tired of list prices?',
|
||
hint: 'Low volume means low commission for sales — our member tiers get better as you ship more, or become an agent and earn',
|
||
label: 'I want member or agent rates',
|
||
},
|
||
zh: {
|
||
title: '拿不到优惠价?',
|
||
hint: '单少提成低,销售爱搭不理 → 会员成长体系发越多越优惠,还能做代理拿分成',
|
||
label: '我想看会员价/代理价',
|
||
},
|
||
},
|
||
{
|
||
id: 'track',
|
||
icon: 'track',
|
||
en: {
|
||
title: 'Lost mid-route?',
|
||
hint: 'Order → pickup → warehouse → clearance → delivery, with exception alerts',
|
||
label: 'Where is my shipment from pickup to delivery?',
|
||
},
|
||
zh: {
|
||
title: '货丢在半路?',
|
||
hint: '下单→揽收→出入库→清关→派送,一站可视,异常有人盯',
|
||
label: '这票货从揽收到派送卡在哪',
|
||
},
|
||
},
|
||
{
|
||
id: 'hard',
|
||
icon: 'hard',
|
||
en: {
|
||
title: 'Others declined it?',
|
||
hint: 'Battery, special cargo, cosmetics — if others refuse, ask us first',
|
||
label: 'Others declined this cargo. Can you take it?',
|
||
},
|
||
zh: {
|
||
title: '别人不接的单?',
|
||
hint: '3C带电、特殊品、化妆品,别处拒收,先问我们',
|
||
label: '别人不接的货,你们能发吗?',
|
||
},
|
||
},
|
||
{
|
||
id: 'compliance',
|
||
icon: 'clearance',
|
||
en: {
|
||
title: 'Need export compliance?',
|
||
hint: 'Formal declaration, VAT refund, and invoice-free exemption',
|
||
label: 'I need export compliance — declaration, refund, or exemption',
|
||
},
|
||
zh: {
|
||
title: '想出口合规?',
|
||
hint: '正式报关、出口退税、无票免征,我们都能办',
|
||
label: '想出口合规:正式报关 / 退税 / 无票免征',
|
||
},
|
||
},
|
||
{
|
||
id: 'human',
|
||
icon: 'human',
|
||
en: {
|
||
title: 'Stuck in a group chat?',
|
||
hint: 'No mega group chats — one owner from quote to POD, even for small volume',
|
||
label: 'Give me a dedicated advisor, no group chat',
|
||
},
|
||
zh: {
|
||
title: '拉群找不到人?',
|
||
hint: '不拉大群、不踢皮球;单量少也有人认,报价跟到签收',
|
||
label: '给我一个专属顾问,别拉群',
|
||
},
|
||
},
|
||
];
|
||
|
||
const PARTNERS = [
|
||
{
|
||
id: 'lazada',
|
||
group: 'platform',
|
||
icon: 'assets/img/partners/lazada.svg',
|
||
label: { zh: 'Lazada', en: 'Lazada' },
|
||
ask: {
|
||
zh: 'Lazada 店铺怎么授权、同步订单做一件代发?',
|
||
en: 'How do I authorize a Lazada shop and sync orders for dropship?',
|
||
},
|
||
},
|
||
{
|
||
id: 'shopee',
|
||
group: 'platform',
|
||
icon: 'assets/img/partners/shopee.svg',
|
||
label: { zh: 'Shopee', en: 'Shopee' },
|
||
ask: {
|
||
zh: 'Shopee 店铺怎么授权同步订单?',
|
||
en: 'How do I authorize a Shopee shop and sync orders?',
|
||
},
|
||
},
|
||
{
|
||
id: 'tiktok',
|
||
group: 'platform',
|
||
icon: 'assets/img/partners/tiktok.svg',
|
||
label: { zh: 'TikTok', en: 'TikTok' },
|
||
ask: {
|
||
zh: 'TikTok 店铺订单怎么同步到你们系统?',
|
||
en: 'How do TikTok shop orders sync into your system?',
|
||
},
|
||
},
|
||
{
|
||
id: 'temu',
|
||
group: 'platform',
|
||
icon: 'assets/img/partners/temu.svg',
|
||
label: { zh: 'Temu', en: 'Temu' },
|
||
ask: {
|
||
zh: 'Temu 店铺怎么授权同步、一件代发?',
|
||
en: 'How do I authorize Temu for order sync and dropship?',
|
||
},
|
||
},
|
||
{
|
||
id: 'dhl',
|
||
group: 'carrier',
|
||
icon: 'assets/img/partners/dhl.svg',
|
||
label: { zh: 'DHL', en: 'DHL' },
|
||
ask: {
|
||
zh: '有没有 DHL 线路可以询价?',
|
||
en: 'Do you have DHL lanes I can quote?',
|
||
},
|
||
},
|
||
{
|
||
id: 'fedex',
|
||
group: 'carrier',
|
||
icon: 'assets/img/partners/fedex.svg',
|
||
label: { zh: 'FedEx', en: 'FedEx' },
|
||
ask: {
|
||
zh: '有没有 FedEx 线路可以询价?',
|
||
en: 'Do you have FedEx lanes I can quote?',
|
||
},
|
||
},
|
||
{
|
||
id: 'ups',
|
||
group: 'carrier',
|
||
icon: 'assets/img/partners/ups.svg',
|
||
label: { zh: 'UPS', en: 'UPS' },
|
||
ask: {
|
||
zh: '有没有 UPS 线路报价?',
|
||
en: 'Do you have UPS lanes I can quote?',
|
||
},
|
||
},
|
||
{
|
||
id: '3pe',
|
||
group: 'carrier',
|
||
label: { zh: '3PE', en: '3PE' },
|
||
ask: {
|
||
zh: '3PE 国际快递怎么走、怎么报价?',
|
||
en: 'How does 3PE express work, and how do I get a quote?',
|
||
},
|
||
},
|
||
];
|
||
|
||
function iconMarkup(name) {
|
||
const illus = {
|
||
quote:
|
||
'<ellipse cx="27" cy="44" rx="13" ry="3" fill="#C7923E" opacity="0.28"/>' +
|
||
'<path d="M17 23.5c0-1.5 1.2-2.7 2.7-2.7H29c2.6 0 4.7 2.1 4.7 4.7v1.3h3.4c1.7 0 3 1.5 2.8 3.2l-.9 7.5c-.2 1.6-1.5 2.7-3.1 2.7H19.7c-1.5 0-2.7-1.2-2.7-2.7V23.5z" fill="url(#ai-g-quote)"/>' +
|
||
'<path d="M20.2 20.8v-1.3c0-2.2 1.8-4 4-4h5.6c2.2 0 4 1.8 4 4v1.3" stroke="#B58232" stroke-width="2.2" stroke-linecap="round"/>' +
|
||
'<circle cx="28" cy="32.5" r="3.4" fill="#FFF8EC"/>' +
|
||
'<path d="M28 30.6v3.8M26.5 32h3" stroke="#C7923E" stroke-width="1.5" stroke-linecap="round"/>',
|
||
track:
|
||
'<ellipse cx="27" cy="44" rx="13" ry="3" fill="#2A9BB5" opacity="0.28"/>' +
|
||
'<path d="M11 28h17.8l4.1-7.1c.4-.7 1.1-1.1 1.9-1.1H40.2c1.1 0 2 .9 2 2v12.6c0 .8-.7 1.5-1.5 1.5H11V28z" fill="url(#ai-g-track)"/>' +
|
||
'<path d="M11 22h15.2v18H13c-1.1 0-2-.9-2-2V22z" fill="#054F74"/>' +
|
||
'<rect x="14.2" y="25.6" width="6.6" height="4" rx="1" fill="#E6F6FA"/>' +
|
||
'<circle cx="17.6" cy="39.6" r="3.4" fill="#1D2129"/>' +
|
||
'<circle cx="17.6" cy="39.6" r="1.5" fill="#C9CDD4"/>' +
|
||
'<circle cx="35.6" cy="39.6" r="3.4" fill="#1D2129"/>' +
|
||
'<circle cx="35.6" cy="39.6" r="1.5" fill="#C9CDD4"/>' +
|
||
'<path d="M6.2 24.6h4M5.2 28h3.6M6.6 31.6h3.2" stroke="#2A9BB5" stroke-width="1.8" stroke-linecap="round" opacity="0.9"/>',
|
||
hard:
|
||
'<ellipse cx="27" cy="44" rx="12" ry="3" fill="#E07A5F" opacity="0.28"/>' +
|
||
'<path d="M27 12.6c1.1 0 2.1.58 2.64 1.52l12.1 21.1c.54.95.53 2.12-.03 3.06A3.05 3.05 0 0 1 39.07 40H14.93c-1.1 0-2.12-.58-2.64-1.52a3.05 3.05 0 0 1-.03-3.06l12.1-21.1A3.05 3.05 0 0 1 27 12.6z" fill="url(#ai-g-hard)"/>' +
|
||
'<rect x="25.4" y="21.2" width="3.2" height="9.4" rx="1.6" fill="#fff"/>' +
|
||
'<circle cx="27" cy="34.3" r="2" fill="#fff"/>',
|
||
clearance:
|
||
'<ellipse cx="27" cy="44" rx="12" ry="3" fill="#054F74" opacity="0.22"/>' +
|
||
'<path d="M16.5 13.5c0-1.4 1.1-2.5 2.5-2.5h10.4c.7 0 1.4.3 1.9.8l6.2 6.2c.5.5.8 1.2.8 1.9V36c0 1.4-1.1 2.5-2.5 2.5H19c-1.4 0-2.5-1.1-2.5-2.5V13.5z" fill="url(#ai-g-clear)"/>' +
|
||
'<path d="M30.2 11.2V16c0 1.1.9 2 2 2h4.7L30.2 11.2z" fill="#9ED7E4"/>' +
|
||
'<rect x="21" y="23.5" width="13.5" height="2.2" rx="1.1" fill="#fff" opacity="0.92"/>' +
|
||
'<rect x="21" y="28.5" width="9.5" height="2.2" rx="1.1" fill="#fff" opacity="0.75"/>' +
|
||
'<circle cx="37.5" cy="34.2" r="7.6" fill="#2A9BB5"/>' +
|
||
'<path d="M34.6 34.4l1.8 1.8 4.2-4.4" stroke="#fff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>',
|
||
human:
|
||
'<ellipse cx="27" cy="44" rx="13" ry="3" fill="#2A9BB5" opacity="0.28"/>' +
|
||
'<circle cx="27" cy="18.2" r="7.2" fill="url(#ai-g-human)"/>' +
|
||
'<path d="M14.8 40.2c1.4-7.2 5.8-10.8 12.2-10.8s10.8 3.6 12.2 10.8" fill="#054F74"/>' +
|
||
'<path d="M18.5 20.2c.4-5.2 3.4-8.4 8.5-8.4s8.1 3.2 8.5 8.4" stroke="#9ED7E4" stroke-width="2" stroke-linecap="round" opacity="0.9"/>' +
|
||
'<rect x="15.6" y="19.4" width="3.4" height="5.4" rx="1.7" fill="#2A9BB5"/>' +
|
||
'<rect x="35" y="19.4" width="3.4" height="5.4" rx="1.7" fill="#2A9BB5"/>' +
|
||
'<path d="M22.4 28.6h9.2c1.4 0 2.5 1.1 2.5 2.5v2.2H19.9v-2.2c0-1.4 1.1-2.5 2.5-2.5z" fill="#E6F6FA"/>',
|
||
};
|
||
|
||
const defs = {
|
||
quote:
|
||
'<linearGradient id="ai-g-quote" x1="17" y1="16" x2="40" y2="40" gradientUnits="userSpaceOnUse">' +
|
||
'<stop stop-color="#E0B35A"/><stop offset="1" stop-color="#C7923E"/></linearGradient>',
|
||
track:
|
||
'<linearGradient id="ai-g-track" x1="11" y1="19" x2="42" y2="40" gradientUnits="userSpaceOnUse">' +
|
||
'<stop stop-color="#7DCFDD"/><stop offset="1" stop-color="#2A9BB5"/></linearGradient>',
|
||
hard:
|
||
'<linearGradient id="ai-g-hard" x1="12" y1="12" x2="42" y2="40" gradientUnits="userSpaceOnUse">' +
|
||
'<stop stop-color="#F0A090"/><stop offset="1" stop-color="#E07A5F"/></linearGradient>',
|
||
clearance:
|
||
'<linearGradient id="ai-g-clear" x1="16" y1="11" x2="40" y2="39" gradientUnits="userSpaceOnUse">' +
|
||
'<stop stop-color="#5EC4D6"/><stop offset="1" stop-color="#054F74"/></linearGradient>',
|
||
human:
|
||
'<linearGradient id="ai-g-human" x1="20" y1="11" x2="34" y2="26" gradientUnits="userSpaceOnUse">' +
|
||
'<stop stop-color="#7DCFDD"/><stop offset="1" stop-color="#2A9BB5"/></linearGradient>',
|
||
};
|
||
|
||
if (illus[name]) {
|
||
return (
|
||
'<svg class="ai-icon ai-icon--illus" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||
'<defs>' +
|
||
defs[name] +
|
||
'</defs>' +
|
||
illus[name] +
|
||
'</svg>'
|
||
);
|
||
}
|
||
|
||
// Legacy stroke icons for guide / other surfaces
|
||
const art = {
|
||
lane:
|
||
'<path d="M4.5 21.5c4.2-8.4 10.4-12.6 15-12.6 5.8 0 8.8 4.6 9.6 8.8"/>' +
|
||
'<path d="M4.5 21.5h23"/>' +
|
||
'<path class="ai-icon__accent-fill" d="M9.8 16.4l13.8-4.2-2.4 6.2-4.6-1.2-2.8 3.6-2.1-1.1 1.9-3.6-3.8.3z"/>',
|
||
advise:
|
||
'<circle cx="16" cy="16" r="10"/>' +
|
||
'<path d="M16 6.2v2.2M16 23.6v2.2M6.2 16h2.2M23.6 16h2.2"/>' +
|
||
'<path class="ai-icon__accent-fill" d="M16 9.4l3.6 11.2L16 18.2 12.4 20.6 16 9.4z"/>',
|
||
refund:
|
||
'<ellipse cx="13.2" cy="11.4" rx="7.4" ry="2.8"/>' +
|
||
'<path d="M5.8 11.4v8.2c0 1.6 3.3 2.8 7.4 2.8s7.4-1.2 7.4-2.8v-8.2"/>' +
|
||
'<ellipse cx="13.2" cy="19.6" rx="7.4" ry="2.8"/>' +
|
||
'<path class="ai-icon__accent" d="M24.2 19.5v-9.2M21 13.4l3.2-3.2 3.2 3.2"/>',
|
||
exempt:
|
||
'<path d="M16 4.8l10 3.8v8.4c0 6.2-4.4 9.8-10 11.2-5.6-1.4-10-5-10-11.2V8.6L16 4.8z"/>' +
|
||
'<path class="ai-icon__accent" d="M11.4 16.4l3.2 3.2 6.4-7"/>',
|
||
filing:
|
||
'<circle cx="16" cy="16" r="10"/>' +
|
||
'<circle cx="16" cy="16" r="6.1"/>' +
|
||
'<circle class="ai-icon__accent-fill" cx="16" cy="16" r="2.1"/>' +
|
||
'<path class="ai-icon__accent" d="M16 6.2v2.6M16 23.2v2.6M6.2 16h2.6M23.2 16h2.6"/>',
|
||
warehouse:
|
||
'<path d="M4.2 14.4L16 5.6l11.8 8.8v12.1H4.2V14.4z"/>' +
|
||
'<path d="M16 5.6v21.3"/>' +
|
||
'<rect class="ai-icon__accent" x="12" y="18" width="8" height="8.9" rx="1.2"/>' +
|
||
'<path class="ai-icon__accent-fill" d="M8.2 12.6h3.2v3.4H8.2zM20.6 12.6h3.2v3.4h-3.2z"/>',
|
||
};
|
||
|
||
const body = art[name] || art.lane;
|
||
return (
|
||
'<svg class="ai-icon" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
|
||
body +
|
||
'</svg>'
|
||
);
|
||
}
|
||
|
||
function resolveInitialLang() {
|
||
const saved = localStorage.getItem('ide-language');
|
||
if (saved === 'zh' || saved === 'en') return saved;
|
||
return 'zh';
|
||
}
|
||
|
||
let lang = resolveInitialLang();
|
||
let isBusy = false;
|
||
let sessionMessages = [];
|
||
let hasUserMessage = false;
|
||
let threads = [];
|
||
let activeThreadId = null;
|
||
let flow = null;
|
||
|
||
let shell;
|
||
let welcomeEl;
|
||
let messagesEl;
|
||
let followupEl;
|
||
let inputEl;
|
||
let sendBtn;
|
||
let intentGridEl;
|
||
let mainScrollEl;
|
||
let historyListEl;
|
||
let globeController = null;
|
||
let coverageOpen = false;
|
||
let newChatTipTimer = null;
|
||
let routesCatalog = null;
|
||
let routesLoadState = 'idle';
|
||
let routesGroupFilter = 'all';
|
||
let routesSearch = '';
|
||
let routesExpanded = {};
|
||
|
||
const MAP_REGION_EU = new Set(['eu'].concat(COVER_EU.map((item) => item.mapId)));
|
||
const MAP_REGION_AM = new Set(['us', 'mx', 'ca']);
|
||
const MAP_REGION_AU = new Set(['au', 'nz']);
|
||
const MAP_REGION_SEA = new Set(COVER_SEA.map((item) => item.mapId));
|
||
|
||
function mapRegionForId(mapId) {
|
||
if (MAP_REGION_EU.has(mapId)) return 'eu';
|
||
if (MAP_REGION_AM.has(mapId)) return 'am';
|
||
if (MAP_REGION_AU.has(mapId)) return 'au';
|
||
if (MAP_REGION_SEA.has(mapId)) return 'sea';
|
||
return 'cn';
|
||
}
|
||
|
||
function preferOmsH5() {
|
||
const ua = navigator.userAgent || '';
|
||
return /MicroMessenger/i.test(ua) || /Android|iPhone|iPod|iPad|Mobile/i.test(ua);
|
||
}
|
||
|
||
function resolveOmsOrigin() {
|
||
const configured = window.IDE_OMS_ORIGIN || localStorage.getItem('ide-oms-origin');
|
||
if (configured) return String(configured).replace(/\/$/, '');
|
||
const path = (window.location.pathname || '').replace(/\\/g, '/');
|
||
const port = window.location.port;
|
||
const isFile = window.location.protocol === 'file:';
|
||
const standalone =
|
||
isFile ||
|
||
/\/IDE官网(\/|$)/.test(path) ||
|
||
(port && !['80', '443', '5173', '4173'].includes(port) && !document.getElementById('app'));
|
||
if (standalone) {
|
||
const host = window.location.hostname || '127.0.0.1';
|
||
return `${isFile ? 'http:' : window.location.protocol}//${host}:5173`;
|
||
}
|
||
return window.location.origin;
|
||
}
|
||
|
||
function omsUrl(pathname) {
|
||
return `${resolveOmsOrigin()}${pathname}`;
|
||
}
|
||
|
||
function loginTrackUrl(number) {
|
||
const target = preferOmsH5() ? '/h5/login' : '/login';
|
||
const redirect = preferOmsH5() ? '/h5/orders' : '/home/workplace';
|
||
const url = new URL(omsUrl(target));
|
||
url.searchParams.set('redirect', redirect);
|
||
if (number) url.searchParams.set('track', number);
|
||
return url.toString();
|
||
}
|
||
|
||
function loginPortalUrl() {
|
||
return omsUrl(preferOmsH5() ? '/h5/login' : '/login');
|
||
}
|
||
|
||
function registerUrl(dest) {
|
||
// 官网「注册询价」走客户登录页微信扫码(注册/登录同一入口)
|
||
const url = new URL(loginPortalUrl());
|
||
url.searchParams.set('from', 'site');
|
||
if (dest) url.searchParams.set('dest', String(dest));
|
||
return url.toString();
|
||
}
|
||
|
||
function registerActions(dest) {
|
||
const actions = el('div', 'ai-card__actions');
|
||
const reg = el('a', 'ai-card-link is-primary', t('registerNow'));
|
||
reg.href = registerUrl(dest);
|
||
const login = el('a', 'ai-card-link', t('loginExisting'));
|
||
login.href = loginPortalUrl();
|
||
actions.appendChild(reg);
|
||
actions.appendChild(login);
|
||
return actions;
|
||
}
|
||
|
||
function isHomePage() {
|
||
const path = (window.location.pathname || '').replace(/\\/g, '/');
|
||
return (
|
||
/(?:^|\/)index\.html?$/.test(path) ||
|
||
path.endsWith('/') ||
|
||
path === '' ||
|
||
/\/IDE官网\/?$/.test(path)
|
||
);
|
||
}
|
||
|
||
function t(key) {
|
||
return COPY[lang][key] || COPY.en[key] || key;
|
||
}
|
||
|
||
function setTrustLabel(selector, text) {
|
||
const label = shell.querySelector(selector + ' .ai-trust-label');
|
||
if (label) label.textContent = text;
|
||
}
|
||
|
||
function intentCopy(intent) {
|
||
return lang === 'zh' ? intent.zh : intent.en;
|
||
}
|
||
|
||
function newThreadId() {
|
||
return 't_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||
}
|
||
|
||
function deriveThreadTitle(messages) {
|
||
const firstUser = messages.find((m) => m.role === 'user');
|
||
if (!firstUser) return t('newChat');
|
||
const text = String(firstUser.text || '').trim();
|
||
return text.length > 28 ? `${text.slice(0, 28)}…` : text;
|
||
}
|
||
|
||
function formatThreadTime(ts) {
|
||
const date = new Date(ts);
|
||
const now = new Date();
|
||
const sameDay =
|
||
date.getFullYear() === now.getFullYear() &&
|
||
date.getMonth() === now.getMonth() &&
|
||
date.getDate() === now.getDate();
|
||
if (sameDay) {
|
||
return date.toLocaleTimeString(lang === 'zh' ? 'zh-CN' : 'en-US', {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
}
|
||
return date.toLocaleDateString(lang === 'zh' ? 'zh-CN' : 'en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
});
|
||
}
|
||
|
||
function getActiveThread() {
|
||
return threads.find((thread) => thread.id === activeThreadId) || null;
|
||
}
|
||
|
||
function syncActiveThreadMessages() {
|
||
const thread = getActiveThread();
|
||
if (!thread) return;
|
||
thread.messages = sessionMessages.map((msg) => ({
|
||
role: msg.role,
|
||
text: msg.text,
|
||
card: msg.card || null,
|
||
}));
|
||
thread.flow = flow ? { ...flow, data: { ...(flow.data || {}) } } : null;
|
||
thread.updatedAt = Date.now();
|
||
if (thread.messages.some((m) => m.role === 'user')) {
|
||
thread.title = deriveThreadTitle(thread.messages);
|
||
}
|
||
}
|
||
|
||
function saveThreads() {
|
||
syncActiveThreadMessages();
|
||
localStorage.setItem(STORAGE_THREADS, JSON.stringify(threads));
|
||
if (activeThreadId) {
|
||
localStorage.setItem(STORAGE_ACTIVE_THREAD, activeThreadId);
|
||
}
|
||
}
|
||
|
||
function applyThreadMessages(thread) {
|
||
sessionMessages = thread?.messages
|
||
? thread.messages.map((msg) => ({ role: msg.role, text: msg.text, card: msg.card || null }))
|
||
: [];
|
||
hasUserMessage = sessionMessages.some((m) => m.role === 'user');
|
||
flow = thread?.flow ? { ...thread.flow, data: { ...(thread.flow.data || {}) } } : null;
|
||
}
|
||
|
||
function createThread(setActive) {
|
||
const thread = {
|
||
id: newThreadId(),
|
||
title: t('newChat'),
|
||
updatedAt: Date.now(),
|
||
messages: [],
|
||
flow: null,
|
||
};
|
||
threads.unshift(thread);
|
||
if (setActive !== false) {
|
||
activeThreadId = thread.id;
|
||
applyThreadMessages(thread);
|
||
}
|
||
saveThreads();
|
||
return thread;
|
||
}
|
||
|
||
function loadThreads() {
|
||
try {
|
||
const raw = localStorage.getItem(STORAGE_THREADS);
|
||
threads = raw ? JSON.parse(raw) : [];
|
||
if (!Array.isArray(threads)) threads = [];
|
||
} catch {
|
||
threads = [];
|
||
}
|
||
|
||
activeThreadId = localStorage.getItem(STORAGE_ACTIVE_THREAD);
|
||
|
||
if (threads.length === 0) {
|
||
try {
|
||
const legacy = sessionStorage.getItem(STORAGE_SESSION);
|
||
const legacyMessages = legacy ? JSON.parse(legacy) : [];
|
||
if (Array.isArray(legacyMessages) && legacyMessages.length > 0) {
|
||
const thread = createThread(true);
|
||
thread.messages = legacyMessages;
|
||
thread.title = deriveThreadTitle(legacyMessages);
|
||
applyThreadMessages(thread);
|
||
saveThreads();
|
||
sessionStorage.removeItem(STORAGE_SESSION);
|
||
return;
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
createThread(true);
|
||
return;
|
||
}
|
||
|
||
if (!activeThreadId || !threads.some((thread) => thread.id === activeThreadId)) {
|
||
activeThreadId = threads[0].id;
|
||
}
|
||
|
||
applyThreadMessages(getActiveThread());
|
||
}
|
||
|
||
function syncHeaderOffset() {
|
||
const header = document.getElementById('header');
|
||
const height = header ? Math.round(header.getBoundingClientRect().height) : 72;
|
||
document.documentElement.style.setProperty('--ai-header-h', `${height}px`);
|
||
}
|
||
|
||
function el(tag, className, text) {
|
||
const node = document.createElement(tag);
|
||
if (className) node.className = className;
|
||
if (text != null) node.textContent = text;
|
||
return node;
|
||
}
|
||
|
||
function renderCard(card) {
|
||
if (!card || !card.type) return null;
|
||
const wrap = el('div', `ai-card ai-card--${card.type}`);
|
||
const zh = lang === 'zh';
|
||
|
||
if (card.type === 'choice') {
|
||
if (card.kicker) wrap.appendChild(el('p', 'ai-card__kicker', card.kicker));
|
||
if (card.title) wrap.appendChild(el('p', 'ai-card__title', card.title));
|
||
const actions = el('div', 'ai-card__actions');
|
||
(card.options || []).forEach((opt) => {
|
||
const btn = el('button', opt.primary ? 'ai-card-opt is-primary' : 'ai-card-opt', opt.label);
|
||
btn.type = 'button';
|
||
btn.dataset.choice = opt.id;
|
||
btn.dataset.flow = card.flowId || '';
|
||
btn.dataset.label = opt.label;
|
||
actions.appendChild(btn);
|
||
});
|
||
wrap.appendChild(actions);
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'quote') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '询价' : 'Quote'));
|
||
wrap.appendChild(el('p', 'ai-card__title', zh ? '注册登记后即可询价' : 'Register to see rates'));
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
card.dest
|
||
? zh
|
||
? '发到' + card.dest + '可以走。官网不提供询价,完成注册后在客户系统里按线路看明码价。'
|
||
: 'We ship to ' + card.dest + '. Quotes are not on this site — register, then see list rates in the customer system.'
|
||
: zh
|
||
? '官网不提供询价。完成注册登记后,即可在客户系统里按线路看明码价,一口价不砍。'
|
||
: 'This site does not quote. Register first, then see list rates by lane in the customer system — no haggling.'
|
||
)
|
||
);
|
||
wrap.appendChild(registerActions(card.dest));
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'track') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '真实轨迹' : 'Live tracking'));
|
||
wrap.appendChild(el('p', 'ai-card__title', t('loginTrack')));
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
card.number
|
||
? zh
|
||
? `单号 ${card.number} 已记下。登录后看本票真实节点。`
|
||
: `Got ${card.number}. Sign in to see live scans for this shipment.`
|
||
: zh
|
||
? '有单号请登录查看。询价需先注册登记。'
|
||
: 'Sign in if you have a number. Quotes require registration.'
|
||
)
|
||
);
|
||
const actions = el('div', 'ai-card__actions');
|
||
const login = el('a', 'ai-card-link is-primary', t('loginTrack'));
|
||
login.href = loginTrackUrl(card.number || '');
|
||
actions.appendChild(login);
|
||
wrap.appendChild(actions);
|
||
wrap.appendChild(intentActions(['quote', 'human']));
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'hard-refund' || card.type === 'hard-exempt' || card.type === 'hard-basic') {
|
||
const meta = {
|
||
'hard-refund': {
|
||
kicker: zh ? '9610 · 利润最优' : '9610 · best margin',
|
||
title: zh ? '出口退税(免抵退)' : 'Export VAT refund',
|
||
body: zh
|
||
? '有进项专票:汇总正式报关、完整 10 位 HS、报关单退税联 + 专票。进项税额按退税率退还。'
|
||
: 'You have special VAT invoices: formal consolidated declaration, full 10-digit HS, declaration refund copy + invoice. Input VAT is refunded at the rebate rate.',
|
||
},
|
||
'hard-exempt': {
|
||
kicker: zh ? '9610 · 合规推荐' : '9610 · compliant',
|
||
title: zh ? '无票免征' : 'Exemption without invoice',
|
||
body: zh
|
||
? '无专票但要免税:简易/汇总均可,HS 前 4 位,综服平台登记。出口环节增值税/消费税免征。'
|
||
: 'No special invoice, but you want exemption: simplified or consolidated filing, first 4 HS digits, comprehensive-service platform. Export VAT/excise is exempted.',
|
||
},
|
||
'hard-basic': {
|
||
kicker: zh ? '9610 · 快捷出货' : '9610 · fastest exit',
|
||
title: zh ? '基础通关服务' : 'Basic clearance',
|
||
body: zh
|
||
? '仅需出货:物流商统一简易申报。出得快,但税务未规范可能被视同内销(增值税 + 所得税 + 滞纳金)。量起来后建议升到免征或退税,IDE 可评估路径。'
|
||
: 'Ship-only: carrier files a simplified declaration. Fast, but unstructured tax handling can be treated as domestic sales. We can review an upgrade path when volume grows.',
|
||
},
|
||
}[card.type];
|
||
if (card.type === 'hard-basic') wrap.classList.add('ai-card--warn');
|
||
wrap.appendChild(el('p', 'ai-card__kicker', meta.kicker));
|
||
wrap.appendChild(el('p', 'ai-card__title', meta.title));
|
||
wrap.appendChild(el('p', 'ai-card__body', meta.body));
|
||
wrap.appendChild(intentActions(['human']));
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'contact') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '人工顾问' : 'Human advisor'));
|
||
wrap.appendChild(el('p', 'ai-card__title', zh ? '专人跟,不排队工单' : 'One advisor — not a ticket queue'));
|
||
const rows = el('div', 'ai-card__rows');
|
||
[
|
||
[zh ? '电话' : 'Phone', PHONE],
|
||
[zh ? '微信客服' : 'WeCom', zh ? '扫码 / 手机直达' : 'Scan or tap to chat'],
|
||
[zh ? '微信号' : 'WeChat ID', WECHAT],
|
||
[zh ? '邮箱' : 'Email', EMAIL],
|
||
[zh ? '地址' : 'Address', zh ? ADDRESS_ZH : ADDRESS_EN],
|
||
].forEach(([label, value]) => {
|
||
const row = el('div', 'ai-card__row');
|
||
row.appendChild(el('span', '', label));
|
||
row.appendChild(el('strong', '', value));
|
||
rows.appendChild(row);
|
||
});
|
||
wrap.appendChild(rows);
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
zh
|
||
? '点「在线咨询」用微信对接顾问。询价请先注册登记。'
|
||
: 'Tap Live chat to reach an advisor on WeChat. Register first if you need a quote.'
|
||
)
|
||
);
|
||
wrap.appendChild(contactActions());
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'company') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '关于我们' : 'About IDE'));
|
||
wrap.appendChild(el('p', 'ai-card__title', zh ? '中小卖家的跨境物流顾问' : 'Cross-border logistics for SMB sellers'));
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
zh
|
||
? '深圳宝安运营,备案主体可核(' + ICP + ')。主做美欧与东南亚小包。接小批量、带电、别人不接的 9610。一人从报价跟到售后。'
|
||
: 'Operated from Bao’an, Shenzhen (ICP ' + ICP + '). US, EU and Southeast Asia parcels. We take low volume, battery, and 9610 others decline. One advisor from quote to after-sales.'
|
||
)
|
||
);
|
||
appendCardItems(wrap, [
|
||
{ title: zh ? '主线:美加墨 / 欧盟 / 澳新 / 东南亚' : 'Lanes: US-CA-MX / EU / AU-NZ / SEA', hint: zh ? '欧盟 27 国及英国,东南亚列马来、新、菲、泰、越、印尼。' : 'EU 27 and the UK, plus Malaysia, Singapore, Philippines, Thailand, Vietnam, Indonesia.' },
|
||
{ title: zh ? '难单先给出货路径' : 'Tough orders get a ship path first', hint: zh ? '别人不接的带电、无票、超长,先保证出得去,再谈退税或免征。' : 'Battery, no invoice, oversize — we get it out, then pick refund or exemption.' },
|
||
{ title: zh ? '微信 ' + WECHAT : 'WeChat ' + WECHAT, hint: zh ? '卖家常用这一个号找顾问,底栏也可复制。' : 'The number sellers use. Copy it from the bar below.' },
|
||
]);
|
||
wrap.appendChild(intentActions(['quote', 'human']));
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'pricing') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '价格' : 'Pricing'));
|
||
wrap.appendChild(el('p', 'ai-card__metric', zh ? '按线路明码报价,一口价不砍' : 'Lane list rates — no haggling'));
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
zh
|
||
? '中小卖家按计费重看价即可,不磨、不砍。税怎么走取决于有没有专票。询价请先注册登记。'
|
||
: 'SMB sellers see the list rate by chargeable weight — no haggling. Tax savings still depend on invoices. Register first to quote.'
|
||
)
|
||
);
|
||
appendCardItems(wrap, [
|
||
{ title: zh ? '计费重 = 实重和体积重取大' : 'Chargeable weight = max(actual, volumetric)', hint: zh ? '体积重按渠道系数折算,超长另计。' : 'Volumetric uses the lane factor; oversize is extra.' },
|
||
{ title: zh ? '明码一口价' : 'Published list rate', hint: zh ? '注册登记后,在客户系统里按线路看价,不用讨价还价。' : 'After you register, see list rates by lane in the customer system. No bargaining.' },
|
||
{ title: zh ? '有票退税,无票免征或基础通关' : 'Refund / exemption / basic clearance', hint: zh ? '差在税,不在口头运费。带电、敏感货加收。' : 'The gap is tax, not a spoken freight. Battery and sensitive cargo surcharge.' },
|
||
]);
|
||
wrap.appendChild(registerActions());
|
||
return wrap;
|
||
}
|
||
|
||
if (card.type === 'warehouse') {
|
||
wrap.appendChild(el('p', 'ai-card__kicker', zh ? '仓储' : 'Warehouse'));
|
||
wrap.appendChild(el('p', 'ai-card__title', zh ? '仓储一件代发' : 'Warehouse dropship'));
|
||
wrap.appendChild(
|
||
el(
|
||
'p',
|
||
'ai-card__body',
|
||
zh
|
||
? '入仓、拣货打包,订单来了就发出去。中小卖家不用自己养仓。开通请先注册登记,在客户系统里操作。'
|
||
: 'Inbound, pick and pack, ship when the order lands. Register first — warehouse dropship runs in the customer system.'
|
||
)
|
||
);
|
||
appendCardItems(wrap, [
|
||
{ title: zh ? '入仓管理' : 'Inbound', hint: zh ? '收货上架,库存可查。' : 'Receive, put away, inventory you can see.' },
|
||
{ title: zh ? '一件代发' : 'Dropship', hint: zh ? '订单过来就拣货打包发出。' : 'Pick, pack, and ship as orders come in.' },
|
||
{ title: zh ? '对接出库' : 'Outbound', hint: zh ? '和专线、9610 出货打通。' : 'Tied to parcel lanes and 9610 exit.' },
|
||
]);
|
||
wrap.appendChild(registerActions());
|
||
return wrap;
|
||
}
|
||
|
||
return wrap;
|
||
}
|
||
|
||
function appendCardItems(wrap, items) {
|
||
const list = el('div', 'ai-card__list');
|
||
items.forEach((item) => {
|
||
const row = el('div', 'ai-card__item');
|
||
row.appendChild(el('strong', '', item.title));
|
||
if (item.hint) row.appendChild(el('span', '', item.hint));
|
||
list.appendChild(row);
|
||
});
|
||
wrap.appendChild(list);
|
||
}
|
||
|
||
function intentActions(ids) {
|
||
const zh = lang === 'zh';
|
||
const labels = {
|
||
quote: zh ? '去注册询价 →' : 'Register to quote →',
|
||
hard: zh ? '疑难单 / 9610 →' : 'Tough / 9610 →',
|
||
human: zh ? '联系顾问 →' : 'Talk to advisor →',
|
||
};
|
||
const actions = el('div', 'ai-card__actions');
|
||
ids.forEach((id, index) => {
|
||
if (id === 'quote') {
|
||
const link = el('a', index === 0 ? 'ai-card-opt is-primary' : 'ai-card-opt', labels.quote);
|
||
link.href = registerUrl();
|
||
actions.appendChild(link);
|
||
return;
|
||
}
|
||
const btn = el('button', index === 0 ? 'ai-card-opt is-primary' : 'ai-card-opt', labels[id] || id);
|
||
btn.type = 'button';
|
||
btn.dataset.intent = id;
|
||
actions.appendChild(btn);
|
||
});
|
||
return actions;
|
||
}
|
||
|
||
function isWeChatEnv() {
|
||
return /MicroMessenger/i.test(navigator.userAgent);
|
||
}
|
||
|
||
function isMobileEnv() {
|
||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
|
||
}
|
||
|
||
function closeKfQrModal() {
|
||
document.getElementById('ide-kf-modal')?.remove();
|
||
}
|
||
|
||
function showKfQrModal(url) {
|
||
closeKfQrModal();
|
||
const zh = lang === 'zh';
|
||
const modal = document.createElement('div');
|
||
modal.id = 'ide-kf-modal';
|
||
modal.className = 'ide-kf-modal';
|
||
modal.setAttribute('role', 'dialog');
|
||
modal.setAttribute('aria-modal', 'true');
|
||
modal.setAttribute('aria-label', zh ? '微信扫码咨询' : 'Scan to chat on WeChat');
|
||
const qrSrc =
|
||
'https://api.qrserver.com/v1/create-qr-code/?size=220x220&margin=8&data=' +
|
||
encodeURIComponent(url);
|
||
modal.innerHTML = `
|
||
<div class="ide-kf-modal__backdrop" data-kf-close></div>
|
||
<div class="ide-kf-modal__panel">
|
||
<button type="button" class="ide-kf-modal__close" data-kf-close aria-label="${zh ? '关闭' : 'Close'}">
|
||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||
</button>
|
||
<p class="ide-kf-modal__kicker">${zh ? '企业微信客服' : 'WeCom support'}</p>
|
||
<h3 class="ide-kf-modal__title">${zh ? '微信扫码咨询' : 'Scan with WeChat'}</h3>
|
||
<p class="ide-kf-modal__hint">${zh ? '打开手机微信,扫一扫下方二维码即可对话顾问' : 'Open WeChat on your phone and scan the code to chat'}</p>
|
||
<div class="ide-kf-modal__qr-wrap">
|
||
<img class="ide-kf-modal__qr" src="${qrSrc}" width="220" height="220" alt="WeCom QR">
|
||
<p class="ide-kf-modal__qr-fallback" hidden>${zh ? '二维码加载失败,请点下方打开客服页后用微信扫码' : 'QR failed to load — open the support page and scan there'}</p>
|
||
</div>
|
||
<a class="ide-kf-modal__open" href="${url}" target="_blank" rel="noopener noreferrer">${zh ? '在新窗口打开客服页' : 'Open support page'}</a>
|
||
</div>
|
||
`;
|
||
const qrImg = modal.querySelector('.ide-kf-modal__qr');
|
||
const qrFallback = modal.querySelector('.ide-kf-modal__qr-fallback');
|
||
qrImg?.addEventListener('error', () => {
|
||
qrImg.hidden = true;
|
||
if (qrFallback) qrFallback.hidden = false;
|
||
});
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target.closest('[data-kf-close]')) closeKfQrModal();
|
||
});
|
||
document.addEventListener(
|
||
'keydown',
|
||
function onEsc(e) {
|
||
if (e.key === 'Escape') {
|
||
closeKfQrModal();
|
||
document.removeEventListener('keydown', onEsc);
|
||
}
|
||
},
|
||
{ once: true }
|
||
);
|
||
document.body.appendChild(modal);
|
||
}
|
||
|
||
/** 手机 / 微信内:直跳唤起会话;PC:弹二维码浮层 */
|
||
function openWecomKf(e) {
|
||
if (e) e.preventDefault();
|
||
const url = WECOM_KF_URL;
|
||
if (isWeChatEnv() || isMobileEnv()) {
|
||
window.location.href = url;
|
||
return;
|
||
}
|
||
showKfQrModal(url);
|
||
}
|
||
|
||
function contactActions() {
|
||
const actions = el('div', 'ai-card__actions');
|
||
const kf = el('button', 'ai-card-link is-primary', lang === 'zh' ? '在线咨询' : 'Live chat');
|
||
kf.type = 'button';
|
||
kf.addEventListener('click', openWecomKf);
|
||
const tel = el('a', 'ai-card-link', PHONE);
|
||
tel.href = `tel:${PHONE}`;
|
||
const mail = el('a', 'ai-card-link', EMAIL);
|
||
mail.href = `mailto:${EMAIL}`;
|
||
actions.appendChild(kf);
|
||
actions.appendChild(tel);
|
||
actions.appendChild(mail);
|
||
return actions;
|
||
}
|
||
|
||
function renderShell() {
|
||
const existing = document.getElementById('ai-advisor-shell');
|
||
if (existing) {
|
||
shell = existing;
|
||
return;
|
||
}
|
||
if (!isHomePage()) return;
|
||
|
||
shell = document.createElement('div');
|
||
shell.id = 'ai-advisor-shell';
|
||
shell.innerHTML = `
|
||
<div class="ai-shell-bg" aria-hidden="true">
|
||
<div class="ai-shell-bg__veil" aria-hidden="true"></div>
|
||
</div>
|
||
<div class="ai-stage-maps" aria-hidden="true"></div>
|
||
<div class="ai-advisor-layout">
|
||
<div class="ai-advisor-app">
|
||
<div class="ai-stage-tools">
|
||
<button type="button" class="ai-sidebar-toggle" aria-label="Chat history">
|
||
<i class="bi bi-clock-history" aria-hidden="true"></i>
|
||
</button>
|
||
<button type="button" class="ai-new-chat-ghost">
|
||
<i class="bi bi-plus-lg" aria-hidden="true"></i>
|
||
<span class="ai-new-chat-ghost__label"></span>
|
||
</button>
|
||
</div>
|
||
<div class="ai-app-main">
|
||
<section class="ai-welcome">
|
||
<div class="ai-welcome__hero">
|
||
<div class="ai-welcome-plane" aria-hidden="true">
|
||
<img src="assets/img/hero-plane-3d.png" alt="">
|
||
</div>
|
||
<div class="ai-welcome__stack">
|
||
<div class="ai-welcome__deck">
|
||
<div class="ai-welcome__intro">
|
||
<h2 class="ai-welcome-title"></h2>
|
||
<p class="ai-welcome-sub"></p>
|
||
</div>
|
||
<div class="ai-welcome__foot">
|
||
<div class="ai-welcome-stats-block">
|
||
<ul class="ai-welcome-stats" aria-label="Service highlights"></ul>
|
||
<div class="ai-cover-panel" hidden>
|
||
<p class="ai-cover-panel__hint"></p>
|
||
<div class="ai-cover__chips"></div>
|
||
<div class="ai-cover__eu" hidden></div>
|
||
<div class="ai-cover__sea">
|
||
<div class="ai-cover__sea-chips"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ai-welcome__actions">
|
||
<a class="ai-welcome-cta" href="#"></a>
|
||
<a class="ai-welcome-human" href="#contact"></a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ai-welcome__command">
|
||
<div class="ai-welcome-compose">
|
||
<p class="ai-welcome-compose__lead"></p>
|
||
<div class="ai-composer-slot--welcome"></div>
|
||
</div>
|
||
<div class="ai-intent-grid"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<div class="ai-messages" role="log" aria-live="polite"></div>
|
||
</div>
|
||
<div class="ai-composer-dock">
|
||
<div class="ai-followup" role="toolbar"></div>
|
||
<div class="ai-composer-slot--chat"></div>
|
||
<div class="ai-composer-wrap">
|
||
<div class="ai-new-chat-tip" hidden role="status" aria-live="polite">
|
||
<span class="ai-new-chat-tip__text"></span>
|
||
</div>
|
||
<div class="ai-composer">
|
||
<span class="ai-composer__caustic" aria-hidden="true"></span>
|
||
<span class="ai-composer__sheen" aria-hidden="true"></span>
|
||
<form class="ai-composer-form" autocomplete="off">
|
||
<textarea class="ai-composer-input" rows="1" maxlength="2000"></textarea>
|
||
<button type="submit" class="ai-composer-send" aria-label="联系客服"><i class="bi bi-send-fill"></i></button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
<div class="ai-trust-bar">
|
||
<button type="button" class="ai-trust-wechat" title="${WECHAT}">
|
||
<i class="bi bi-wechat" aria-hidden="true"></i>
|
||
<span class="ai-trust-label"></span>
|
||
</button>
|
||
<div class="ai-partner-marquee" aria-label="">
|
||
<span class="ai-partner-marquee__tag"></span>
|
||
<div class="ai-partner-marquee__viewport">
|
||
<div class="ai-partner-marquee__track"></div>
|
||
</div>
|
||
</div>
|
||
<a class="ai-trust-icp" href="https://beian.miit.gov.cn" target="_blank" rel="noopener">
|
||
<i class="bi bi-shield-check" aria-hidden="true"></i>
|
||
<span class="ai-trust-label"></span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<aside class="ai-history-sidebar" aria-label="Chat history">
|
||
<p class="ai-history-sidebar__title"></p>
|
||
<button type="button" class="ai-new-chat-btn">
|
||
<i class="bi bi-plus-lg" aria-hidden="true"></i>
|
||
<span class="ai-new-chat-btn__label"></span>
|
||
</button>
|
||
<div class="ai-history-list" role="list"></div>
|
||
</aside>
|
||
<aside class="ai-guide-panel" id="ai-9610-panel" aria-label="9610">
|
||
<div class="ai-guide-panel__head">
|
||
<div>
|
||
<p class="ai-guide-panel__kicker"></p>
|
||
<h3 class="ai-guide-panel__title"></h3>
|
||
</div>
|
||
<button type="button" class="ai-guide-panel__close" aria-label="Close">
|
||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<p class="ai-guide-panel__sub"></p>
|
||
<div class="ai-guide-panel__cards"></div>
|
||
</aside>
|
||
<aside class="ai-routes-panel" id="ai-routes-panel" aria-label="Routes">
|
||
<div class="ai-routes-panel__head">
|
||
<div class="ai-routes-panel__intro">
|
||
<h3 class="ai-routes-panel__title"></h3>
|
||
<p class="ai-routes-panel__sub"></p>
|
||
</div>
|
||
<button type="button" class="ai-routes-panel__close" aria-label="Close">
|
||
<i class="bi bi-x-lg" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
<div class="ai-routes-panel__tools">
|
||
<label class="ai-routes-search">
|
||
<i class="bi bi-search" aria-hidden="true"></i>
|
||
<input type="search" class="ai-routes-search__input" autocomplete="off" />
|
||
</label>
|
||
<div class="ai-routes-groups" role="tablist"></div>
|
||
</div>
|
||
<div class="ai-routes-panel__body"></div>
|
||
<div class="ai-routes-panel__foot">
|
||
<button type="button" class="ai-routes-sticky"></button>
|
||
</div>
|
||
</aside>
|
||
<button type="button" class="ai-history-backdrop" aria-hidden="true" tabindex="-1"></button>
|
||
`;
|
||
document.body.appendChild(shell);
|
||
|
||
welcomeEl = shell.querySelector('.ai-welcome');
|
||
messagesEl = shell.querySelector('.ai-messages');
|
||
followupEl = shell.querySelector('.ai-followup');
|
||
inputEl = shell.querySelector('.ai-composer-input');
|
||
sendBtn = shell.querySelector('.ai-composer-send');
|
||
intentGridEl = shell.querySelector('.ai-intent-grid');
|
||
mainScrollEl = shell.querySelector('.ai-app-main');
|
||
historyListEl = shell.querySelector('.ai-history-list');
|
||
bindMainScroll();
|
||
|
||
bindStageChrome();
|
||
|
||
shell.querySelector('.ai-new-chat-btn').addEventListener('click', () => startNewChat());
|
||
shell.querySelector('.ai-new-chat-ghost').addEventListener('click', () => startNewChat());
|
||
shell.querySelector('.ai-sidebar-toggle').addEventListener('click', () => toggleHistorySidebar());
|
||
shell.querySelector('.ai-history-backdrop').addEventListener('click', () => closeDrawers());
|
||
shell.querySelector('.ai-guide-panel__close').addEventListener('click', () => close9610Panel());
|
||
shell.querySelector('.ai-guide-panel__cards').addEventListener('click', (e) => {
|
||
const card = e.target.closest('[data-guide-ask]');
|
||
if (!card || isBusy) return;
|
||
close9610Panel();
|
||
sendUserMessage(card.dataset.guideAsk, { intentId: card.dataset.guideIntent || 'hard' });
|
||
});
|
||
|
||
shell.querySelector('.ai-routes-panel__close').addEventListener('click', () => closeRoutesPanel());
|
||
shell.querySelector('.ai-routes-search__input').addEventListener('input', (e) => {
|
||
routesSearch = String(e.target.value || '').trim();
|
||
renderRoutesList();
|
||
});
|
||
shell.querySelector('.ai-routes-groups').addEventListener('click', (e) => {
|
||
const btn = e.target.closest('[data-routes-group]');
|
||
if (!btn) return;
|
||
routesGroupFilter = btn.dataset.routesGroup || 'all';
|
||
renderRoutesGroups();
|
||
renderRoutesList();
|
||
});
|
||
shell.querySelector('.ai-routes-panel__body').addEventListener('click', (e) => {
|
||
const expand = e.target.closest('[data-routes-expand]');
|
||
if (expand) {
|
||
const id = expand.dataset.routesExpand;
|
||
routesExpanded[id] = !routesExpanded[id];
|
||
renderRoutesList();
|
||
return;
|
||
}
|
||
const ask = e.target.closest('[data-routes-ask]');
|
||
if (ask) {
|
||
const text = ask.dataset.routesAsk;
|
||
closeRoutesPanel();
|
||
prefillComposer(text);
|
||
return;
|
||
}
|
||
const retry = e.target.closest('[data-routes-retry]');
|
||
if (retry) {
|
||
routesCatalog = null;
|
||
routesLoadState = 'idle';
|
||
void loadRoutesCatalog(true);
|
||
}
|
||
});
|
||
shell.querySelector('.ai-routes-sticky').addEventListener('click', () => {
|
||
const text =
|
||
lang === 'zh'
|
||
? '按目的国帮我推荐合适线路,并说明时效和贸易条款'
|
||
: 'Recommend a shipping lane by destination, with transit time and trade terms';
|
||
closeRoutesPanel();
|
||
prefillComposer(text);
|
||
});
|
||
|
||
shell.querySelector('.ai-trust-wechat').addEventListener('click', openWecomKf);
|
||
|
||
shell.querySelector('.ai-composer-form').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
sendUserMessage(inputEl.value.trim());
|
||
});
|
||
|
||
inputEl.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
sendUserMessage(inputEl.value.trim());
|
||
}
|
||
});
|
||
|
||
inputEl.addEventListener('input', () => {
|
||
hideNewChatTip();
|
||
inputEl.style.height = 'auto';
|
||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
||
syncComposerSendState();
|
||
});
|
||
|
||
syncComposerSendState();
|
||
|
||
messagesEl.addEventListener('click', (e) => {
|
||
const intentBtn = e.target.closest('[data-intent]');
|
||
if (intentBtn && !isBusy) {
|
||
const intent = INTENTS.find((item) => item.id === intentBtn.dataset.intent);
|
||
const label = intent ? intentCopy(intent).label : intentBtn.textContent;
|
||
sendUserMessage(label, { intentId: intentBtn.dataset.intent });
|
||
return;
|
||
}
|
||
const btn = e.target.closest('[data-choice]');
|
||
if (!btn || isBusy) return;
|
||
sendUserMessage(btn.dataset.label || btn.textContent, {
|
||
kind: 'choice',
|
||
flowId: btn.dataset.flow,
|
||
choice: btn.dataset.choice,
|
||
});
|
||
});
|
||
}
|
||
|
||
function buildCoverageMarkers() {
|
||
const markers = [];
|
||
const pushMarker = (id, label) => {
|
||
const coords = MAP_COORDS[id];
|
||
if (!coords) return;
|
||
markers.push({ id, coords, label, regionId: mapRegionForId(id) });
|
||
};
|
||
|
||
COVER_REGIONS.forEach((region) => {
|
||
const label = lang === 'zh' ? region.label.zh : region.label.en;
|
||
pushMarker(region.id, label);
|
||
});
|
||
|
||
COVER_EU.forEach((item) => {
|
||
pushMarker(item.mapId, coverName(item));
|
||
});
|
||
|
||
COVER_SEA.forEach((item) => {
|
||
pushMarker(item.mapId, coverName(item));
|
||
});
|
||
|
||
return markers;
|
||
}
|
||
|
||
function setCoverageOpen(open) {
|
||
coverageOpen = !!open;
|
||
const panel = shell?.querySelector('.ai-cover-panel');
|
||
const trigger = shell?.querySelector('.ai-welcome-stats__trigger');
|
||
const block = shell?.querySelector('.ai-welcome-stats-block');
|
||
if (panel) {
|
||
if (coverageOpen) panel.removeAttribute('hidden');
|
||
else panel.setAttribute('hidden', '');
|
||
}
|
||
if (trigger) trigger.setAttribute('aria-expanded', coverageOpen ? 'true' : 'false');
|
||
if (block) block.classList.toggle('is-open', coverageOpen);
|
||
if (!globeController) return;
|
||
globeController.setActive(coverageOpen);
|
||
if (coverageOpen) {
|
||
globeController.setMarkers(buildCoverageMarkers());
|
||
globeController.resetView();
|
||
} else {
|
||
globeController.setMarkers([]);
|
||
globeController.resetView();
|
||
}
|
||
window.requestAnimationFrame(syncMainOverflow);
|
||
}
|
||
|
||
function focusCoverageOnMap(mapId) {
|
||
if (!mapId) return;
|
||
if (!coverageOpen) setCoverageOpen(true);
|
||
if (!globeController) return;
|
||
globeController.setMarkers(buildCoverageMarkers());
|
||
globeController.focusMarker(mapId);
|
||
shell?.querySelectorAll('.ai-cover-chip.is-map-active').forEach((node) => {
|
||
node.classList.remove('is-map-active');
|
||
});
|
||
const chip = shell?.querySelector('.ai-cover-chip[data-map-id="' + mapId + '"]');
|
||
if (chip) chip.classList.add('is-map-active');
|
||
}
|
||
|
||
function renderWelcomeStats() {
|
||
if (!shell) return;
|
||
const listEl = shell.querySelector('.ai-welcome-stats');
|
||
if (!listEl) return;
|
||
listEl.setAttribute('aria-label', t('statsAria'));
|
||
listEl.innerHTML = HERO_STATS.map((item) => {
|
||
const value = lang === 'zh' ? item.value.zh : item.value.en;
|
||
const label = lang === 'zh' ? item.label.zh : item.label.en;
|
||
if (item.interactive) {
|
||
return (
|
||
'<li class="ai-welcome-stats__item ai-welcome-stats__item--coverage">' +
|
||
'<button type="button" class="ai-welcome-stats__trigger" aria-expanded="' +
|
||
(coverageOpen ? 'true' : 'false') +
|
||
'">' +
|
||
'<strong class="ai-welcome-stats__value">' +
|
||
value +
|
||
'</strong>' +
|
||
'<span class="ai-welcome-stats__meta">' +
|
||
'<span class="ai-welcome-stats__label">' +
|
||
label +
|
||
'</span>' +
|
||
'<span class="ai-welcome-stats__hint">' +
|
||
t('statsCoverageHint') +
|
||
'</span></span></button></li>'
|
||
);
|
||
}
|
||
return (
|
||
'<li class="ai-welcome-stats__item">' +
|
||
'<strong class="ai-welcome-stats__value">' +
|
||
value +
|
||
'</strong>' +
|
||
'<span class="ai-welcome-stats__label">' +
|
||
label +
|
||
'</span></li>'
|
||
);
|
||
}).join('');
|
||
|
||
const trigger = listEl.querySelector('.ai-welcome-stats__trigger');
|
||
if (trigger && !trigger.dataset.bound) {
|
||
trigger.dataset.bound = '1';
|
||
trigger.addEventListener('click', () => {
|
||
setCoverageOpen(!coverageOpen);
|
||
});
|
||
}
|
||
}
|
||
|
||
function applyWelcomeTitle() {
|
||
if (!shell) return;
|
||
const titleEl = shell.querySelector('.ai-welcome-title');
|
||
if (!titleEl) return;
|
||
const useShort = window.matchMedia('(max-width: 640px)').matches;
|
||
titleEl.textContent = useShort ? t('welcomeTitleShort') : t('welcomeTitle');
|
||
}
|
||
|
||
function renderStageGlobe() {
|
||
if (!shell || !window.IDEStageGlobe) return;
|
||
const el = shell.querySelector('.ai-stage-maps');
|
||
const cnHost = shell.querySelector('.ai-welcome-plane');
|
||
globeController =
|
||
window.IDEStageGlobe.render(el, { lang, cnHost }) || el?.__globeApi || null;
|
||
if (coverageOpen && globeController) {
|
||
globeController.setActive(true);
|
||
globeController.setMarkers(buildCoverageMarkers());
|
||
}
|
||
}
|
||
|
||
function bindStageChrome() {
|
||
if (!shell || shell.dataset.chromeBound === '1') return;
|
||
shell.dataset.chromeBound = '1';
|
||
}
|
||
|
||
function renderIntentGrid() {
|
||
if (!intentGridEl) return;
|
||
intentGridEl.innerHTML = '';
|
||
INTENTS.forEach((intent) => {
|
||
const copy = intentCopy(intent);
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ai-intent-door' + (intent.id === 'quote' ? ' is-primary' : '');
|
||
btn.dataset.intent = intent.id;
|
||
btn.innerHTML = `
|
||
<span class="ai-intent-door__icon">${iconMarkup(intent.icon)}</span>
|
||
<span class="ai-intent-door__copy">
|
||
<span class="ai-intent-door__title"></span>
|
||
<span class="ai-intent-door__hint"></span>
|
||
</span>
|
||
`;
|
||
btn.querySelector('.ai-intent-door__title').textContent = copy.title;
|
||
btn.querySelector('.ai-intent-door__hint').textContent = copy.hint;
|
||
btn.addEventListener('click', () => onIntentClick(intent));
|
||
intentGridEl.appendChild(btn);
|
||
});
|
||
renderPartnerMarquee();
|
||
renderCoverage();
|
||
}
|
||
|
||
function partnerAsk(partner) {
|
||
return lang === 'zh' ? partner.ask.zh : partner.ask.en;
|
||
}
|
||
|
||
function partnerLabel(partner) {
|
||
return lang === 'zh' ? partner.label.zh : partner.label.en;
|
||
}
|
||
|
||
function prefillComposer(text) {
|
||
if (!inputEl || isBusy || !text) return;
|
||
hideNewChatTip();
|
||
inputEl.value = text;
|
||
inputEl.style.height = 'auto';
|
||
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
|
||
syncComposerSendState();
|
||
inputEl.focus();
|
||
try {
|
||
const len = text.length;
|
||
inputEl.setSelectionRange(len, len);
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
inputEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
|
||
function renderPartnerMarquee() {
|
||
if (!shell) return;
|
||
const root = shell.querySelector('.ai-partner-marquee');
|
||
const track = shell.querySelector('.ai-partner-marquee__track');
|
||
const tag = shell.querySelector('.ai-partner-marquee__tag');
|
||
if (!root || !track || !tag) return;
|
||
|
||
root.setAttribute('aria-label', t('partnerAria'));
|
||
tag.textContent = t('partnerTag');
|
||
track.innerHTML = '';
|
||
|
||
const makeChip = (partner) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ai-partner-chip ai-partner-chip--' + partner.group;
|
||
btn.dataset.partner = partner.id;
|
||
btn.title = partnerAsk(partner);
|
||
if (partner.icon) {
|
||
const img = document.createElement('img');
|
||
img.className = 'ai-partner-chip__logo';
|
||
img.src = partner.icon;
|
||
img.alt = '';
|
||
img.decoding = 'async';
|
||
img.setAttribute('aria-hidden', 'true');
|
||
btn.appendChild(img);
|
||
}
|
||
const label = document.createElement('span');
|
||
label.className = 'ai-partner-chip__label';
|
||
label.textContent = partnerLabel(partner);
|
||
btn.appendChild(label);
|
||
btn.addEventListener('click', () => prefillComposer(partnerAsk(partner)));
|
||
return btn;
|
||
};
|
||
|
||
// Duplicate once for seamless loop (skip when user prefers reduced motion)
|
||
const loopTwice = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
(loopTwice ? [0, 1] : [0]).forEach(() => {
|
||
PARTNERS.forEach((partner) => track.appendChild(makeChip(partner)));
|
||
});
|
||
}
|
||
|
||
function coverName(item) {
|
||
return lang === 'zh' ? item.zh : item.en;
|
||
}
|
||
|
||
function coverChip(label, dest, extraClass, mapId) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = extraClass ? 'ai-cover-chip ' + extraClass : 'ai-cover-chip';
|
||
btn.textContent = label;
|
||
if (dest) btn.dataset.dest = dest;
|
||
if (mapId) btn.dataset.mapId = mapId;
|
||
return btn;
|
||
}
|
||
|
||
function renderCoverage() {
|
||
if (!welcomeEl) return;
|
||
const chipsEl = welcomeEl.querySelector('.ai-cover__chips');
|
||
const euEl = welcomeEl.querySelector('.ai-cover__eu');
|
||
const seaChips = welcomeEl.querySelector('.ai-cover__sea-chips');
|
||
if (!chipsEl || !euEl || !seaChips) return;
|
||
|
||
chipsEl.innerHTML = '';
|
||
euEl.innerHTML = '';
|
||
seaChips.innerHTML = '';
|
||
|
||
COVER_REGIONS.forEach((region) => {
|
||
const label = lang === 'zh' ? region.label.zh : region.label.en;
|
||
const dest = lang === 'zh' ? region.dest.zh : region.dest.en;
|
||
if (region.expand) {
|
||
const btn = coverChip(label, '', 'is-eu', region.id);
|
||
btn.setAttribute('aria-expanded', euEl.hidden ? 'false' : 'true');
|
||
btn.addEventListener('click', () => {
|
||
const open = euEl.hasAttribute('hidden');
|
||
if (open) euEl.removeAttribute('hidden');
|
||
else euEl.setAttribute('hidden', '');
|
||
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
focusCoverageOnMap(region.id);
|
||
window.requestAnimationFrame(syncMainOverflow);
|
||
});
|
||
chipsEl.appendChild(btn);
|
||
return;
|
||
}
|
||
const btn = coverChip(label, dest, '', region.id);
|
||
btn.addEventListener('click', () => {
|
||
focusCoverageOnMap(region.id);
|
||
startQuoteTo(dest);
|
||
});
|
||
chipsEl.appendChild(btn);
|
||
});
|
||
|
||
COVER_EU.forEach((item) => {
|
||
const name = coverName(item);
|
||
const btn = coverChip(name, name, '', item.mapId);
|
||
btn.addEventListener('click', () => {
|
||
focusCoverageOnMap(item.mapId);
|
||
startQuoteTo(name);
|
||
});
|
||
euEl.appendChild(btn);
|
||
});
|
||
|
||
COVER_SEA.forEach((item) => {
|
||
const name = coverName(item);
|
||
const btn = coverChip(name, name, '', item.mapId);
|
||
btn.addEventListener('click', () => {
|
||
focusCoverageOnMap(item.mapId);
|
||
startQuoteTo(name);
|
||
});
|
||
seaChips.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function startQuoteTo(dest) {
|
||
if (isBusy || !dest) return;
|
||
const label = lang === 'zh' ? '发到' + dest : 'Ship to ' + dest;
|
||
sendUserMessage(label, { intentId: 'quote', dest });
|
||
}
|
||
|
||
function renderFollowupChips() {
|
||
if (!followupEl) return;
|
||
followupEl.innerHTML = '';
|
||
INTENTS.forEach((intent) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ai-followup-chip';
|
||
btn.textContent = intentCopy(intent).title;
|
||
btn.addEventListener('click', () => onIntentClick(intent));
|
||
followupEl.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function placeComposer(chatting) {
|
||
if (!shell) return;
|
||
const wrap = shell.querySelector('.ai-composer-wrap');
|
||
const welcomeSlot = shell.querySelector('.ai-composer-slot--welcome');
|
||
const chatSlot = shell.querySelector('.ai-composer-slot--chat');
|
||
if (!wrap || !welcomeSlot || !chatSlot) return;
|
||
const target = chatting ? chatSlot : welcomeSlot;
|
||
if (wrap.parentElement !== target) {
|
||
target.appendChild(wrap);
|
||
}
|
||
wrap.classList.toggle('is-welcome', !chatting);
|
||
wrap.classList.toggle('is-docked', chatting);
|
||
const leadEl = shell.querySelector('.ai-welcome-compose__lead');
|
||
if (leadEl) leadEl.hidden = !!chatting;
|
||
}
|
||
|
||
function updateViewMode() {
|
||
const chatting = hasUserMessage || sessionMessages.some((m) => m.role === 'user');
|
||
if (welcomeEl) welcomeEl.classList.toggle('is-hidden', chatting);
|
||
if (messagesEl) messagesEl.classList.toggle('is-active', chatting);
|
||
if (followupEl) followupEl.classList.toggle('is-visible', chatting);
|
||
placeComposer(chatting);
|
||
window.requestAnimationFrame(syncMainOverflow);
|
||
}
|
||
|
||
function renderHistorySidebar() {
|
||
if (!historyListEl) return;
|
||
const sorted = [...threads].sort((a, b) => b.updatedAt - a.updatedAt);
|
||
historyListEl.innerHTML = '';
|
||
|
||
if (sorted.length === 0) {
|
||
historyListEl.appendChild(el('p', 'ai-history-empty', t('emptyHistory')));
|
||
return;
|
||
}
|
||
|
||
sorted.forEach((thread) => {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ai-history-item';
|
||
btn.setAttribute('role', 'listitem');
|
||
if (thread.id === activeThreadId) btn.classList.add('is-active');
|
||
btn.innerHTML = `
|
||
<span class="ai-history-item__icon"><i class="bi bi-chat-left-text" aria-hidden="true"></i></span>
|
||
<span class="ai-history-item__body">
|
||
<span class="ai-history-item__title"></span>
|
||
<span class="ai-history-item__time"></span>
|
||
</span>
|
||
`;
|
||
btn.querySelector('.ai-history-item__title').textContent = thread.title || t('newChat');
|
||
btn.querySelector('.ai-history-item__time').textContent = formatThreadTime(thread.updatedAt);
|
||
btn.addEventListener('click', () => switchThread(thread.id));
|
||
historyListEl.appendChild(btn);
|
||
});
|
||
}
|
||
|
||
function switchThread(threadId) {
|
||
if (isBusy || threadId === activeThreadId) {
|
||
closeHistorySidebar();
|
||
return;
|
||
}
|
||
hideNewChatTip();
|
||
syncActiveThreadMessages();
|
||
activeThreadId = threadId;
|
||
applyThreadMessages(getActiveThread());
|
||
saveThreads();
|
||
renderHistorySidebar();
|
||
renderMessages();
|
||
updateViewMode();
|
||
closeHistorySidebar();
|
||
inputEl.focus();
|
||
}
|
||
|
||
function hideNewChatTip() {
|
||
const tip = shell?.querySelector('.ai-new-chat-tip');
|
||
const wrap = shell?.querySelector('.ai-composer-wrap');
|
||
if (newChatTipTimer) {
|
||
clearTimeout(newChatTipTimer);
|
||
newChatTipTimer = null;
|
||
}
|
||
if (tip) {
|
||
tip.classList.remove('is-visible');
|
||
tip.hidden = true;
|
||
}
|
||
wrap?.classList.remove('has-new-chat-tip');
|
||
}
|
||
|
||
function showNewChatTip() {
|
||
const tip = shell?.querySelector('.ai-new-chat-tip');
|
||
const wrap = shell?.querySelector('.ai-composer-wrap');
|
||
const textEl = tip?.querySelector('.ai-new-chat-tip__text');
|
||
if (!tip || !wrap || !textEl) return;
|
||
if (newChatTipTimer) clearTimeout(newChatTipTimer);
|
||
textEl.textContent = t('newChatHint');
|
||
tip.hidden = false;
|
||
wrap.classList.add('has-new-chat-tip');
|
||
// Restart enter animation if already visible
|
||
tip.classList.remove('is-visible');
|
||
void tip.offsetWidth;
|
||
tip.classList.add('is-visible');
|
||
newChatTipTimer = setTimeout(() => hideNewChatTip(), 4200);
|
||
}
|
||
|
||
function startNewChat() {
|
||
if (isBusy) return;
|
||
syncActiveThreadMessages();
|
||
const current = getActiveThread();
|
||
if (current && current.messages.length === 0) {
|
||
closeHistorySidebar();
|
||
updateViewMode();
|
||
inputEl.focus();
|
||
showNewChatTip();
|
||
return;
|
||
}
|
||
createThread(true);
|
||
renderHistorySidebar();
|
||
renderMessages();
|
||
updateViewMode();
|
||
closeHistorySidebar();
|
||
setNavActive('home');
|
||
inputEl.focus();
|
||
showNewChatTip();
|
||
}
|
||
|
||
function toggleHistorySidebar() {
|
||
close9610Panel();
|
||
closeRoutesPanel();
|
||
shell?.classList.toggle('ai-history-open');
|
||
}
|
||
|
||
function closeHistorySidebar() {
|
||
shell?.classList.remove('ai-history-open');
|
||
}
|
||
|
||
function open9610Panel() {
|
||
closeHistorySidebar();
|
||
closeRoutesPanel();
|
||
closeMobileNav();
|
||
shell?.classList.add('ai-guide-open');
|
||
setNavActive('services');
|
||
if (location.hash !== '#services') {
|
||
history.replaceState(null, '', '#services');
|
||
}
|
||
}
|
||
|
||
function close9610Panel() {
|
||
shell?.classList.remove('ai-guide-open');
|
||
if (location.hash === '#services' || location.hash === '#9610') {
|
||
history.replaceState(null, '', location.pathname + location.search);
|
||
setNavActive('home');
|
||
}
|
||
}
|
||
|
||
function openRoutesPanel() {
|
||
closeHistorySidebar();
|
||
close9610Panel();
|
||
closeMobileNav();
|
||
shell?.classList.add('ai-routes-open');
|
||
setNavActive('routes');
|
||
if (location.hash !== '#routes') {
|
||
history.replaceState(null, '', '#routes');
|
||
}
|
||
renderRoutesChrome();
|
||
void loadRoutesCatalog(false);
|
||
}
|
||
|
||
function closeRoutesPanel() {
|
||
shell?.classList.remove('ai-routes-open');
|
||
if (location.hash === '#routes') {
|
||
history.replaceState(null, '', location.pathname + location.search);
|
||
setNavActive('home');
|
||
}
|
||
}
|
||
|
||
function closeDrawers() {
|
||
closeHistorySidebar();
|
||
close9610Panel();
|
||
closeRoutesPanel();
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
function unwrapApiData(payload) {
|
||
if (!payload || typeof payload !== 'object') return null;
|
||
let cur = payload;
|
||
for (let i = 0; i < 4; i++) {
|
||
if (Array.isArray(cur.groups) || Array.isArray(cur.Groups) || Array.isArray(cur.items) || Array.isArray(cur.Items)) {
|
||
return cur;
|
||
}
|
||
const next = cur.data ?? cur.Data ?? cur.result ?? cur.Result;
|
||
if (!next || typeof next !== 'object') break;
|
||
cur = next;
|
||
}
|
||
return cur;
|
||
}
|
||
|
||
function normalizeRoutesCatalog(raw) {
|
||
const data = unwrapApiData(raw) || {};
|
||
const groups = (data.groups || data.Groups || []).map((g) => ({
|
||
code: String(g.code ?? g.Code ?? '').trim(),
|
||
name: String(g.name ?? g.Name ?? g.code ?? g.Code ?? '').trim(),
|
||
})).filter((g) => g.code);
|
||
const items = (data.items || data.Items || []).map((item) => ({
|
||
id: item.id ?? item.Id,
|
||
productName: String(item.productName ?? item.ProductName ?? '').trim(),
|
||
productGroup: String(item.productGroup ?? item.ProductGroup ?? '').trim() || '_ungrouped',
|
||
productGroupName: String(item.productGroupName ?? item.ProductGroupName ?? '').trim(),
|
||
minTimeliness: item.minTimeliness ?? item.MinTimeliness ?? null,
|
||
maxTimeliness: item.maxTimeliness ?? item.MaxTimeliness ?? null,
|
||
tradeTerms: String(item.tradeTerms ?? item.TradeTerms ?? '').trim().toUpperCase(),
|
||
productDescription: String(item.productDescription ?? item.ProductDescription ?? '').trim(),
|
||
})).filter((item) => item.productName);
|
||
return { groups, items };
|
||
}
|
||
|
||
async function loadRoutesCatalog(force) {
|
||
if (!force && (routesLoadState === 'loading' || (routesLoadState === 'ready' && routesCatalog))) {
|
||
renderRoutesList();
|
||
return;
|
||
}
|
||
routesLoadState = 'loading';
|
||
renderRoutesList();
|
||
try {
|
||
const res = await fetch(omsUrl('/channel/sales-product/portal/catalog'), {
|
||
method: 'GET',
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'omit',
|
||
});
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
const json = await res.json();
|
||
routesCatalog = normalizeRoutesCatalog(json);
|
||
routesLoadState = 'ready';
|
||
if (routesGroupFilter !== 'all') {
|
||
const exists = routesCatalog.groups.some((g) => g.code === routesGroupFilter);
|
||
if (!exists) routesGroupFilter = 'all';
|
||
}
|
||
renderRoutesGroups();
|
||
renderRoutesList();
|
||
} catch (_) {
|
||
routesLoadState = 'error';
|
||
routesCatalog = null;
|
||
renderRoutesList();
|
||
}
|
||
}
|
||
|
||
function formatRouteTimeliness(item) {
|
||
const min = item.minTimeliness;
|
||
const max = item.maxTimeliness;
|
||
if (min != null && max != null && min !== max) return min + '–' + max;
|
||
if (min != null && max != null) return String(min);
|
||
if (min != null) return String(min) + '+';
|
||
if (max != null) return '≤' + max;
|
||
return '';
|
||
}
|
||
|
||
function tradeTermsMeta(code) {
|
||
if (code === 'DDP') return { code: 'DDP', hint: t('routesDdpHint') };
|
||
if (code === 'DDU') return { code: 'DDU', hint: t('routesDduHint') };
|
||
return null;
|
||
}
|
||
|
||
function filteredRouteItems() {
|
||
const items = routesCatalog?.items || [];
|
||
const q = routesSearch.toLowerCase();
|
||
return items.filter((item) => {
|
||
if (routesGroupFilter !== 'all' && item.productGroup !== routesGroupFilter) return false;
|
||
if (!q) return true;
|
||
return (
|
||
item.productName.toLowerCase().includes(q) ||
|
||
item.productDescription.toLowerCase().includes(q) ||
|
||
item.productGroupName.toLowerCase().includes(q)
|
||
);
|
||
});
|
||
}
|
||
|
||
function renderRoutesChrome() {
|
||
if (!shell) return;
|
||
const title = shell.querySelector('.ai-routes-panel__title');
|
||
const sub = shell.querySelector('.ai-routes-panel__sub');
|
||
const search = shell.querySelector('.ai-routes-search__input');
|
||
const sticky = shell.querySelector('.ai-routes-sticky');
|
||
if (title) title.textContent = t('routesTitle');
|
||
if (sub) sub.textContent = t('routesSub');
|
||
if (search) {
|
||
search.placeholder = t('routesSearchPh');
|
||
search.value = routesSearch;
|
||
}
|
||
if (sticky) sticky.textContent = t('routesSticky');
|
||
renderRoutesGroups();
|
||
renderRoutesList();
|
||
}
|
||
|
||
function renderRoutesGroups() {
|
||
const elGroups = shell?.querySelector('.ai-routes-groups');
|
||
if (!elGroups) return;
|
||
const groups = routesCatalog?.groups || [];
|
||
const chips = [{ code: 'all', name: t('routesAll') }].concat(groups);
|
||
elGroups.innerHTML = chips
|
||
.map((g) => {
|
||
const active = routesGroupFilter === g.code ? ' is-active' : '';
|
||
return (
|
||
'<button type="button" class="ai-routes-chip' +
|
||
active +
|
||
'" data-routes-group="' +
|
||
escapeHtml(g.code) +
|
||
'" role="tab" aria-selected="' +
|
||
(routesGroupFilter === g.code ? 'true' : 'false') +
|
||
'">' +
|
||
escapeHtml(g.name) +
|
||
'</button>'
|
||
);
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function renderRoutesList() {
|
||
const body = shell?.querySelector('.ai-routes-panel__body');
|
||
if (!body) return;
|
||
|
||
if (routesLoadState === 'loading') {
|
||
body.innerHTML =
|
||
'<div class="ai-routes-state">' +
|
||
'<div class="ai-routes-skeleton"></div><div class="ai-routes-skeleton"></div><div class="ai-routes-skeleton"></div>' +
|
||
'<p class="ai-routes-state__text">' +
|
||
escapeHtml(t('routesLoading')) +
|
||
'</p></div>';
|
||
return;
|
||
}
|
||
|
||
if (routesLoadState === 'error') {
|
||
body.innerHTML =
|
||
'<div class="ai-routes-state">' +
|
||
'<p class="ai-routes-state__text">' +
|
||
escapeHtml(t('routesEmpty')) +
|
||
'</p>' +
|
||
'<button type="button" class="ai-routes-retry" data-routes-retry="1">' +
|
||
escapeHtml(t('routesRetry')) +
|
||
'</button></div>';
|
||
return;
|
||
}
|
||
|
||
const items = filteredRouteItems();
|
||
if (!items.length) {
|
||
body.innerHTML =
|
||
'<div class="ai-routes-state"><p class="ai-routes-state__text">' +
|
||
escapeHtml(t('routesEmpty')) +
|
||
'</p></div>';
|
||
return;
|
||
}
|
||
|
||
body.innerHTML = items
|
||
.map((item) => {
|
||
const eta = formatRouteTimeliness(item);
|
||
const terms = tradeTermsMeta(item.tradeTerms);
|
||
const askText =
|
||
lang === 'zh'
|
||
? '想了解【' + item.productName + '】的报价、时效和走货要求'
|
||
: 'I want a quote for [' + item.productName + '], including transit time and shipping requirements';
|
||
const expanded = !!routesExpanded[String(item.id)];
|
||
const desc = item.productDescription;
|
||
const longDesc = desc.length > 72;
|
||
const descHtml = desc
|
||
? '<p class="ai-route-card__desc' +
|
||
(expanded ? ' is-expanded' : '') +
|
||
'">' +
|
||
escapeHtml(desc) +
|
||
'</p>' +
|
||
(longDesc
|
||
? '<button type="button" class="ai-route-card__more" data-routes-expand="' +
|
||
escapeHtml(String(item.id)) +
|
||
'">' +
|
||
escapeHtml(expanded ? t('routesCollapse') : t('routesExpand')) +
|
||
'</button>'
|
||
: '')
|
||
: '';
|
||
const etaBlock = eta
|
||
? '<div class="ai-route-card__eta"><strong>' +
|
||
escapeHtml(eta) +
|
||
'</strong><span>' +
|
||
escapeHtml(t('routesDays')) +
|
||
'</span></div>'
|
||
: '<div class="ai-route-card__eta is-muted"><strong>—</strong><span>' +
|
||
escapeHtml(t('routesAskEta')) +
|
||
'</span></div>';
|
||
const termsHtml = terms
|
||
? '<span class="ai-route-card__terms"><b>' +
|
||
escapeHtml(terms.code) +
|
||
'</b> ' +
|
||
escapeHtml(terms.hint) +
|
||
'</span>'
|
||
: '';
|
||
return (
|
||
'<article class="ai-route-card">' +
|
||
etaBlock +
|
||
'<div class="ai-route-card__main">' +
|
||
'<span class="ai-route-card__group">' +
|
||
escapeHtml(item.productGroupName || item.productGroup) +
|
||
'</span>' +
|
||
'<h4 class="ai-route-card__name">' +
|
||
escapeHtml(item.productName) +
|
||
'</h4>' +
|
||
termsHtml +
|
||
descHtml +
|
||
'<div class="ai-route-card__actions">' +
|
||
'<button type="button" class="ai-route-card__ask" data-routes-ask="' +
|
||
escapeHtml(askText) +
|
||
'">' +
|
||
escapeHtml(t('routesAsk')) +
|
||
'</button>' +
|
||
'<a class="ai-route-card__reg" href="' +
|
||
escapeHtml(registerUrl()) +
|
||
'">' +
|
||
escapeHtml(t('routesRegister')) +
|
||
'</a>' +
|
||
'</div></div></article>'
|
||
);
|
||
})
|
||
.join('');
|
||
}
|
||
|
||
function render9610Panel() {
|
||
if (!shell) return;
|
||
const titleEl = shell.querySelector('.ai-guide-panel__title');
|
||
const subEl = shell.querySelector('.ai-guide-panel__sub');
|
||
const cardsEl = shell.querySelector('.ai-guide-panel__cards');
|
||
if (!titleEl || !subEl || !cardsEl) return;
|
||
const kickerEl = shell.querySelector('.ai-guide-panel__kicker');
|
||
titleEl.textContent = t('guide9610Title');
|
||
if (kickerEl) kickerEl.textContent = t('guide9610Kicker');
|
||
subEl.textContent = t('guide9610Sub');
|
||
cardsEl.innerHTML = GUIDE_SERVICES.map((item) => {
|
||
const copy = lang === 'zh' ? item.zh : item.en;
|
||
const tag = lang === 'zh' ? item.tag.zh : item.tag.en;
|
||
const ask = lang === 'zh' ? item.ask.zh : item.ask.en;
|
||
return (
|
||
'<button type="button" class="ai-guide-card" data-guide-ask="' +
|
||
ask.replace(/"/g, '"') +
|
||
'" data-guide-intent="' +
|
||
(item.intent || 'hard') +
|
||
'">' +
|
||
'<span class="ai-guide-card__icon">' +
|
||
iconMarkup(item.icon) +
|
||
'</span>' +
|
||
'<span class="ai-guide-card__copy">' +
|
||
'<span class="ai-guide-card__head">' +
|
||
'<span class="ai-guide-card__tag">' +
|
||
tag +
|
||
'</span>' +
|
||
'<strong class="ai-guide-card__title">' +
|
||
copy.title +
|
||
'</strong>' +
|
||
'</span>' +
|
||
'<span class="ai-guide-card__body">' +
|
||
copy.body +
|
||
'</span>' +
|
||
'</span>' +
|
||
'</button>'
|
||
);
|
||
}).join('');
|
||
}
|
||
|
||
function renderWelcomeSub() {
|
||
const subEl = shell?.querySelector('.ai-welcome-sub');
|
||
if (!subEl) return;
|
||
subEl.innerHTML =
|
||
t('welcomeSubLead') +
|
||
'<span class="ai-welcome-sub__accent">' +
|
||
t('welcomeSubAccent') +
|
||
'</span>' +
|
||
t('welcomeSubTail');
|
||
}
|
||
|
||
function renderStaticCopy() {
|
||
if (!shell) return;
|
||
shell.querySelector('.ai-history-sidebar__title').textContent = t('historyTitle');
|
||
shell.querySelector('.ai-new-chat-btn__label').textContent = t('newChat');
|
||
shell.querySelector('.ai-new-chat-ghost__label').textContent = t('newChat');
|
||
const tipText = shell.querySelector('.ai-new-chat-tip__text');
|
||
if (tipText) tipText.textContent = t('newChatHint');
|
||
applyWelcomeTitle();
|
||
renderWelcomeSub();
|
||
const heroCta = shell.querySelector('.ai-welcome-cta');
|
||
if (heroCta) {
|
||
heroCta.textContent = t('heroCta');
|
||
heroCta.href = loginPortalUrl();
|
||
}
|
||
const welcomeHuman = shell.querySelector('.ai-welcome-human');
|
||
if (welcomeHuman) welcomeHuman.textContent = t('human') + ' →';
|
||
renderWelcomeStats();
|
||
const mapHint = shell.querySelector('.ai-cover-panel__hint');
|
||
if (mapHint) mapHint.textContent = t('mapZoomHint');
|
||
renderCoverage();
|
||
if (coverageOpen) {
|
||
const panel = shell.querySelector('.ai-cover-panel');
|
||
const block = shell.querySelector('.ai-welcome-stats-block');
|
||
if (panel) panel.removeAttribute('hidden');
|
||
if (block) block.classList.add('is-open');
|
||
if (globeController) {
|
||
globeController.setActive(true);
|
||
globeController.setMarkers(buildCoverageMarkers());
|
||
}
|
||
}
|
||
setTrustLabel('.ai-trust-wechat', t('trustWechat'));
|
||
setTrustLabel('.ai-trust-icp', t('trustIcp'));
|
||
const composeLead = shell.querySelector('.ai-welcome-compose__lead');
|
||
if (composeLead) composeLead.textContent = t('composeLead');
|
||
inputEl.placeholder = t('placeholder');
|
||
if (sendBtn) sendBtn.setAttribute('aria-label', t('sendLabel'));
|
||
|
||
render9610Panel();
|
||
renderRoutesChrome();
|
||
renderIntentGrid();
|
||
renderFollowupChips();
|
||
renderHistorySidebar();
|
||
renderMessages();
|
||
updateViewMode();
|
||
renderStageGlobe();
|
||
}
|
||
|
||
function renderMessages() {
|
||
if (!messagesEl) return;
|
||
messagesEl.innerHTML = '';
|
||
sessionMessages.forEach((msg) => appendMessageRow(msg.role, msg.text, msg.card, false));
|
||
scrollToEnd();
|
||
}
|
||
|
||
function appendMessageRow(role, text, card, save) {
|
||
const row = el('div', `ai-msg-row ai-msg-row--${role}`);
|
||
const avatar = el('div', 'ai-msg-avatar');
|
||
avatar.innerHTML = role === 'assistant' ? '<i class="bi bi-truck"></i>' : '';
|
||
if (role === 'user') avatar.textContent = t('you').charAt(0).toUpperCase();
|
||
|
||
const bubble = el('div', 'ai-msg-bubble');
|
||
if (text) bubble.appendChild(el('p', 'ai-msg-text', text));
|
||
if (card) {
|
||
const cardNode = renderCard(card);
|
||
if (cardNode) bubble.appendChild(cardNode);
|
||
}
|
||
|
||
row.appendChild(avatar);
|
||
row.appendChild(bubble);
|
||
messagesEl.appendChild(row);
|
||
|
||
if (save) {
|
||
sessionMessages.push({ role, text: text || '', card: card || null });
|
||
if (role === 'user') hasUserMessage = true;
|
||
saveThreads();
|
||
updateViewMode();
|
||
renderHistorySidebar();
|
||
}
|
||
|
||
scrollToEnd();
|
||
return row;
|
||
}
|
||
|
||
function showTyping() {
|
||
const row = el('div', 'ai-msg-row ai-msg-row--assistant ai-msg-row--typing');
|
||
row.innerHTML = `
|
||
<div class="ai-msg-avatar"><i class="bi bi-truck"></i></div>
|
||
<div class="ai-msg-bubble"><span></span><span></span><span></span></div>
|
||
`;
|
||
messagesEl.appendChild(row);
|
||
scrollToEnd();
|
||
return row;
|
||
}
|
||
|
||
function scrollToEnd() {
|
||
if (!mainScrollEl) return;
|
||
mainScrollEl.scrollTop = mainScrollEl.scrollHeight;
|
||
syncMainOverflow();
|
||
}
|
||
|
||
function syncMainOverflow() {
|
||
const el = mainScrollEl;
|
||
if (!el) return;
|
||
const max = el.scrollHeight - el.clientHeight;
|
||
const canScroll = max > 12;
|
||
el.classList.toggle('is-scrolled', canScroll && el.scrollTop > 12);
|
||
el.classList.toggle('is-overflowing', canScroll && el.scrollTop < max - 12);
|
||
}
|
||
|
||
function bindMainScroll() {
|
||
if (!mainScrollEl || mainScrollEl.dataset.scrollBound === '1') return;
|
||
mainScrollEl.dataset.scrollBound = '1';
|
||
mainScrollEl.addEventListener('scroll', syncMainOverflow, { passive: true });
|
||
window.addEventListener('resize', syncMainOverflow);
|
||
window.requestAnimationFrame(syncMainOverflow);
|
||
}
|
||
|
||
function looksLikeTracking(text) {
|
||
if (typeof text !== 'string') return false;
|
||
return /^[A-Za-z0-9][A-Za-z0-9\-_]{7,}$/.test(text.replace(/\s+/g, ''));
|
||
}
|
||
|
||
function detectIntent(text) {
|
||
const lower = text.toLowerCase();
|
||
if (/track|tracking|物流|单号|追踪/.test(lower) || looksLikeTracking(text)) return 'track';
|
||
if (/9610|退税|专票|免税|免征|合规|报关|declaration|refund|exempt/.test(lower)) return 'compliance';
|
||
if (/疑难|小批量|不接|带电|3c|特殊品|化妆品/.test(lower)) return 'hard';
|
||
if (/一件代发|代发|dropship|fulfillment/.test(lower)) return 'warehouse';
|
||
if (/仓储|清关|空运|海运|服务|warehous|customs|services/.test(lower)) return 'guide';
|
||
if (/定价|收费|价目|pricing/.test(lower)) return 'pricing';
|
||
if (/price|cost|quote|报价|价格|多少钱/.test(lower)) return 'quote';
|
||
if (/human|人工|顾问|电话|联系我们|联系/.test(lower)) return 'human';
|
||
if (/who are you|about ide|你们是谁|关于我们|关于|公司|靠谱/.test(lower)) return 'company';
|
||
return null;
|
||
}
|
||
|
||
function replyForIntent(intentId, payload) {
|
||
const zh = lang === 'zh';
|
||
|
||
if (intentId === 'human') {
|
||
flow = null;
|
||
return {
|
||
text: zh
|
||
? '点「在线咨询」用微信对接顾问,也可打电话。询价请先注册登记。'
|
||
: 'Tap Live chat for WeChat support, or call us. Register first if you need a quote.',
|
||
card: { type: 'contact' },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'company') {
|
||
flow = null;
|
||
return {
|
||
text: zh ? '先看这张公司卡。要询价请先注册登记。' : 'Here is who we are. Register first when you are ready to quote.',
|
||
card: { type: 'company' },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'guide') {
|
||
flow = null;
|
||
open9610Panel();
|
||
return {
|
||
text: zh ? '服务在右侧,点一张卡继续。' : 'Services are on the right — pick a card to continue.',
|
||
card: null,
|
||
};
|
||
}
|
||
|
||
if (intentId === 'pricing') {
|
||
flow = null;
|
||
return {
|
||
text: zh
|
||
? '计费重取实重和体积重较大值;有票走退税,无票走免征。询价请先注册登记。'
|
||
: 'Chargeable weight is the greater of actual and volumetric. Refund if you have invoices, exemption if not. Register first to quote.',
|
||
card: { type: 'pricing' },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'quote') {
|
||
flow = null;
|
||
const dest = payload && payload.dest ? String(payload.dest) : '';
|
||
return {
|
||
text: dest
|
||
? zh
|
||
? '发到' + dest + '可以走。官网不提供询价,请先注册登记。'
|
||
: 'We ship to ' + dest + '. Quotes are not on this site — please register first.'
|
||
: zh
|
||
? '官网不提供询价。完成注册登记后,即可在客户系统里看明码价。'
|
||
: 'This site does not quote. Register first, then see list rates in the customer system.',
|
||
card: { type: 'quote', dest },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'warehouse') {
|
||
flow = null;
|
||
return {
|
||
text: zh
|
||
? '仓储一件代发要先注册登记,入仓和代发在客户系统里操作。'
|
||
: 'Warehouse dropship needs an account. Inbound and outbound run in the customer system.',
|
||
card: { type: 'warehouse' },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'track') {
|
||
flow = null;
|
||
const number = payload && looksLikeTracking(payload) ? String(payload).replace(/\s+/g, '') : '';
|
||
if (number) {
|
||
try {
|
||
sessionStorage.setItem('ide-pending-track', number);
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
return {
|
||
text: number
|
||
? zh
|
||
? '单号已记下。请登录客户系统看本票真实节点。'
|
||
: 'Number saved. Sign in to see live scans for this shipment.'
|
||
: zh
|
||
? '有单号请登录看真实轨迹。询价需先注册登记。'
|
||
: 'Sign in if you have a tracking number. Quotes require registration.',
|
||
card: { type: 'track', number },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'hard') {
|
||
flow = null;
|
||
return {
|
||
text: zh
|
||
? '带电、特殊品、化妆品这类别人不接的单,我们按品类选线路。先说下货是什么,或点在线顾问细聊。'
|
||
: 'Battery, special cargo, cosmetics — we pick a lane by commodity. Tell us what it is, or talk to an advisor.',
|
||
card: { type: 'contact' },
|
||
};
|
||
}
|
||
|
||
if (intentId === 'compliance') {
|
||
flow = { id: 'hard', step: 0, data: {} };
|
||
return {
|
||
text: zh
|
||
? '出口合规我们能办:正式报关、退税、无票免征。先确认一张票,再给你对应方案。'
|
||
: 'We handle formal declaration, VAT refund, and invoice-free exemption. One tax question first — then the matching scheme.',
|
||
card: {
|
||
type: 'choice',
|
||
flowId: 'hard',
|
||
kicker: '9610',
|
||
title: zh ? '货物能取得增值税专用发票(进项票)吗?' : 'Can you obtain special VAT invoices (input invoices)?',
|
||
options: [
|
||
{ id: 'vat-yes', label: zh ? '能取得专票' : 'Yes, we have invoices', primary: true },
|
||
{ id: 'vat-no', label: zh ? '无法取得专票' : 'No special invoices' },
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
return { text: t('fallback'), card: { type: 'contact' } };
|
||
}
|
||
|
||
function continueFlow(userText, extra) {
|
||
const zh = lang === 'zh';
|
||
|
||
if (flow.id === 'quote') {
|
||
flow = null;
|
||
return replyForIntent('quote');
|
||
}
|
||
|
||
if (flow.id === 'track') {
|
||
return replyForIntent('track', userText);
|
||
}
|
||
|
||
if (flow.id === 'hard') {
|
||
const choice = extra && extra.kind === 'choice' ? extra.choice : null;
|
||
const normalized = (choice || userText || '').toLowerCase();
|
||
|
||
if (flow.step === 0) {
|
||
const yes = choice === 'vat-yes' || /能|有票|yes|invoice/.test(normalized);
|
||
if (yes) {
|
||
flow = null;
|
||
return {
|
||
text: zh ? '有专票,走退税最划算。' : 'With special invoices, refund is the strongest path.',
|
||
card: { type: 'hard-refund' },
|
||
};
|
||
}
|
||
flow.step = 1;
|
||
return {
|
||
text: zh ? '没有专票的话,还想不想用出口免税?' : 'Without special invoices — do you still want export tax exemption?',
|
||
card: {
|
||
type: 'choice',
|
||
flowId: 'hard',
|
||
title: zh ? '是否希望享受出口免税政策?' : 'Do you want the export tax exemption?',
|
||
options: [
|
||
{ id: 'exempt-yes', label: zh ? '希望免税' : 'Yes, exemption', primary: true },
|
||
{ id: 'exempt-no', label: zh ? '仅需出货' : 'Just need to ship' },
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
const wantExempt = choice === 'exempt-yes' || /免税|希望|yes|exempt/.test(normalized);
|
||
flow = null;
|
||
if (wantExempt) {
|
||
return {
|
||
text: zh ? '无票免征,合规且能免出口环节税。' : 'Exemption without invoice — compliant, and export tax is waived.',
|
||
card: { type: 'hard-exempt' },
|
||
};
|
||
}
|
||
return {
|
||
text: zh ? '可以先出货,但税务风险要心里有数。' : 'We can ship first — keep the tax risk in view.',
|
||
card: { type: 'hard-basic' },
|
||
};
|
||
}
|
||
|
||
flow = null;
|
||
return { text: t('fallback'), card: { type: 'contact' } };
|
||
}
|
||
|
||
function buildReply(userText, extra) {
|
||
if (extra && extra.intentId) {
|
||
return replyForIntent(extra.intentId, extra);
|
||
}
|
||
if (flow) {
|
||
return continueFlow(userText, extra);
|
||
}
|
||
const guessed = detectIntent(userText);
|
||
if (guessed === 'track' && looksLikeTracking(userText)) {
|
||
return replyForIntent('track', userText);
|
||
}
|
||
if (guessed) return replyForIntent(guessed, userText);
|
||
return { text: t('fallback'), card: { type: 'contact' } };
|
||
}
|
||
|
||
function syncComposerSendState() {
|
||
if (!sendBtn || !inputEl) return;
|
||
const hasText = Boolean(inputEl.value.trim());
|
||
sendBtn.classList.toggle('is-empty', !hasText && !isBusy);
|
||
sendBtn.disabled = isBusy;
|
||
}
|
||
|
||
function setBusy(busy) {
|
||
isBusy = busy;
|
||
if (inputEl) inputEl.disabled = busy;
|
||
shell?.querySelectorAll(
|
||
'.ai-intent-door, .ai-followup-chip, .ai-card-opt, .ai-cover-chip, .ai-partner-chip'
|
||
).forEach((node) => {
|
||
node.disabled = busy;
|
||
});
|
||
syncComposerSendState();
|
||
}
|
||
|
||
function onIntentClick(intent) {
|
||
if (isBusy) return;
|
||
sendUserMessage(intentCopy(intent).label, { intentId: intent.id });
|
||
}
|
||
|
||
function sendUserMessage(text, extra) {
|
||
hideNewChatTip();
|
||
if (!text || isBusy || !shell) return;
|
||
|
||
appendMessageRow('user', text, null, true);
|
||
if (inputEl) {
|
||
inputEl.value = '';
|
||
inputEl.style.height = 'auto';
|
||
syncComposerSendState();
|
||
}
|
||
|
||
setBusy(true);
|
||
const typing = showTyping();
|
||
const reply = buildReply(text, extra || null);
|
||
|
||
window.setTimeout(() => {
|
||
typing.remove();
|
||
appendMessageRow('assistant', reply.text, reply.card, true);
|
||
setBusy(false);
|
||
inputEl.focus();
|
||
}, 520 + Math.random() * 280);
|
||
}
|
||
|
||
function markLangNavItem() {
|
||
document.getElementById('language-toggle-btn')?.closest('li')?.classList.add('site-nav-tools');
|
||
}
|
||
|
||
const NAV_TOPICS = {
|
||
about: { intentId: 'company', hash: 'about' },
|
||
pricing: { intentId: 'pricing', hash: 'pricing' },
|
||
contact: { intentId: 'human', hash: 'contact' },
|
||
};
|
||
|
||
function closeMobileNav() {
|
||
document.body.classList.remove('mobile-nav-active');
|
||
document.querySelectorAll('.mobile-nav-show').forEach((node) => node.classList.remove('d-none'));
|
||
document.querySelectorAll('.mobile-nav-hide').forEach((node) => node.classList.add('d-none'));
|
||
}
|
||
|
||
function setNavActive(topic) {
|
||
document.querySelectorAll('#navbar ul li > a').forEach((link) => {
|
||
if (link.id === 'language-toggle-btn') return;
|
||
const href = link.getAttribute('href') || '';
|
||
if (!topic || topic === 'home') {
|
||
link.classList.toggle('active', href === '/' || href === 'index.html');
|
||
return;
|
||
}
|
||
link.classList.toggle('active', href === `#${topic}` || href.endsWith(`#${topic}`));
|
||
});
|
||
}
|
||
|
||
function openNavTopic(topic) {
|
||
const spec = NAV_TOPICS[topic];
|
||
if (!spec || isBusy || !shell) return;
|
||
const intent = INTENTS.find((item) => item.id === spec.intentId);
|
||
const labels = {
|
||
company: lang === 'zh' ? '关于我们' : 'About IDE',
|
||
pricing: lang === 'zh' ? '价格怎么算?' : 'How does pricing work?',
|
||
human: lang === 'zh' ? '联系在线顾问' : 'Talk to an online advisor',
|
||
};
|
||
setNavActive(topic);
|
||
if (location.hash !== `#${spec.hash}`) {
|
||
history.replaceState(null, '', `#${spec.hash}`);
|
||
}
|
||
sendUserMessage(labels[spec.intentId], { intentId: spec.intentId });
|
||
void intent;
|
||
}
|
||
|
||
function bindHomeReset() {
|
||
const reset = (e) => {
|
||
if (!isHomePage()) return;
|
||
e.preventDefault();
|
||
startNewChat();
|
||
closeDrawers();
|
||
setNavActive('home');
|
||
if (location.hash) history.replaceState(null, '', location.pathname + location.search);
|
||
};
|
||
document.querySelector('#header .logo')?.addEventListener('click', reset);
|
||
document.querySelector('#navbar a[href="/"], #navbar a[href="index.html"]')?.addEventListener('click', reset);
|
||
|
||
document.querySelectorAll('#navbar a[href^="#"], #navbar a[href*="#"]').forEach((link) => {
|
||
const href = link.getAttribute('href') || '';
|
||
const topic = href.split('#')[1];
|
||
if (!NAV_TOPICS[topic]) return;
|
||
link.addEventListener('click', (e) => {
|
||
if (!isHomePage()) return;
|
||
e.preventDefault();
|
||
closeMobileNav();
|
||
closeDrawers();
|
||
openNavTopic(topic);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('#navbar a[href="#services"], #navbar a[href="#9610"], #navbar a[href="/9610.html"]').forEach((link) => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
open9610Panel();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('#navbar a[href="#routes"]').forEach((link) => {
|
||
link.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
openRoutesPanel();
|
||
});
|
||
});
|
||
|
||
shell?.querySelector('.ai-welcome-human')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
openNavTopic('contact');
|
||
});
|
||
|
||
window.IDEOpenWecomKf = openWecomKf;
|
||
}
|
||
|
||
function init() {
|
||
if (!isHomePage()) return;
|
||
|
||
document.body.classList.add('command-home');
|
||
loadThreads();
|
||
renderShell();
|
||
markLangNavItem();
|
||
bindHomeReset();
|
||
renderStaticCopy();
|
||
syncHeaderOffset();
|
||
window.addEventListener('resize', () => {
|
||
syncHeaderOffset();
|
||
applyWelcomeTitle();
|
||
});
|
||
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeKfQrModal();
|
||
closeDrawers();
|
||
}
|
||
});
|
||
|
||
const bootTopic = (location.hash || '').replace('#', '');
|
||
if (bootTopic === 'services' || bootTopic === '9610') {
|
||
open9610Panel();
|
||
} else if (bootTopic === 'routes') {
|
||
openRoutesPanel();
|
||
} else if (NAV_TOPICS[bootTopic]) {
|
||
openNavTopic(bootTopic);
|
||
}
|
||
}
|
||
|
||
window.IDEAdvisor = {
|
||
setLanguage(nextLang) {
|
||
lang = nextLang === 'zh' ? 'zh' : 'en';
|
||
renderStaticCopy();
|
||
},
|
||
open: () => {
|
||
if (!isHomePage()) window.location.href = '/';
|
||
},
|
||
close: () => closeDrawers(),
|
||
openWecomKf,
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|