新增 IDE 官网静态资源与 D3 分区域地图布局,构建时复制至 dist/site;同步应收应付、代发打包、订单详情等页面优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
1945 lines
74 KiB
JavaScript
1945 lines
74 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 ICP = '粤ICP备19018720号';
|
||
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, a digital one-stop cross-border logistics provider',
|
||
welcomeTitleShort: 'IDE · Cross-border logistics',
|
||
welcomeSubLead: 'Parcels, express, dropship, and 9610 clearance — ',
|
||
welcomeSubAccent: 'built for SMB cross-border sellers',
|
||
welcomeSubTail: '.',
|
||
placeholder: 'Ask about lanes, tracking, or 9610…',
|
||
sendLabel: 'Ask',
|
||
human: 'Talk to a human advisor',
|
||
you: 'You',
|
||
historyTitle: 'Chat history',
|
||
newChat: 'New chat',
|
||
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 ' + WECHAT,
|
||
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',
|
||
},
|
||
zh: {
|
||
welcomeTitle: 'IDE,全球数字化一站式跨境物流服务商',
|
||
welcomeTitleShort: 'IDE · 一站式跨境物流',
|
||
welcomeSubLead: '专线小包、国际快递、一件代发与 9610 报关,',
|
||
welcomeSubAccent: '专为中小跨境卖家而生',
|
||
welcomeSubTail: '。',
|
||
placeholder: '问线路、查轨迹、9610 政策…',
|
||
sendLabel: '提问',
|
||
human: '联系人工顾问',
|
||
you: '你',
|
||
historyTitle: '历史记录',
|
||
newChat: '新会话',
|
||
emptyHistory: '暂无会话',
|
||
fallback: '官网不提供询价。完成注册登记后,即可在客户系统里看明码价。也可以先联系顾问。',
|
||
trustPhone: PHONE,
|
||
trustAddress: ADDRESS_ZH,
|
||
trustWechat: '微信 ' + WECHAT,
|
||
trustEmail: EMAIL,
|
||
trustIcp: ICP,
|
||
guide9610Title: '先发货,再办税',
|
||
guide9610Kicker: '服务',
|
||
guide9610Sub: '先选怎么走货。税务和备案跟在后面,点一张卡就能问。',
|
||
loginTrack: '登录查真实轨迹',
|
||
registerNow: '去注册登记',
|
||
loginExisting: '已有账号,去登录',
|
||
heroCta: '立即发货',
|
||
statsAria: '服务数据',
|
||
statsCoverageHint: '点我查看',
|
||
mapZoomHint: '点击国家,地图上会用定位图标标出',
|
||
composeLead: '先问线路,或选快捷入口',
|
||
},
|
||
};
|
||
|
||
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: 'Clear rates', hint: 'Tailored solutions — better, faster. Register to inquire.', label: 'I need a shipping quote' },
|
||
zh: { title: '明码报价', hint: '量身定制解决方案,更优更快等你来询', label: '我要报价' },
|
||
},
|
||
{
|
||
id: 'track',
|
||
icon: 'track',
|
||
en: { title: 'Track a shipment', hint: 'End-to-end tracking, timely exception alerts', label: 'Track my shipment' },
|
||
zh: { title: '轨迹追踪', hint: '轨迹全程可追踪,异常及时提醒', label: '我想查这票货到哪了' },
|
||
},
|
||
{
|
||
id: 'hard',
|
||
icon: 'hard',
|
||
en: { title: 'Tough orders', hint: 'Refund, exemption, 3C battery, special & cosmetic goods', label: 'Can you take a difficult or 9610 order?' },
|
||
zh: { title: '疑难杂单', hint: '退税、免征、3C带电、特殊品、化妆品', label: '疑难杂单或 9610 怎么发?' },
|
||
},
|
||
{
|
||
id: 'human',
|
||
icon: 'human',
|
||
en: { title: 'Your advisor', hint: 'One person from quote to delivery', label: 'Talk to a human advisor' },
|
||
zh: { title: '专属顾问', hint: '报价跟到签收,售后有人认', label: '联系专属顾问' },
|
||
},
|
||
];
|
||
|
||
function iconMarkup(name) {
|
||
const art = {
|
||
quote:
|
||
'<rect x="6.5" y="4.5" width="14" height="19" rx="3"/>' +
|
||
'<path d="M10 10h7M10 14h7M10 18h4"/>' +
|
||
'<path class="ai-icon__accent" d="M18.2 20.8l5.4-5.4a2 2 0 012.8 0l.6.6a2 2 0 010 2.8l-5.4 5.4-3.4-3.4z"/>' +
|
||
'<circle class="ai-icon__accent-fill" cx="24.2" cy="16.8" r="1.05"/>',
|
||
track:
|
||
'<circle cx="13.5" cy="17" r="8.2"/>' +
|
||
'<ellipse cx="13.5" cy="17" rx="3.2" ry="8.2"/>' +
|
||
'<path d="M5.3 17h16.4"/>' +
|
||
'<path class="ai-icon__accent" d="M23 8.2c0 3.4-4.2 7.3-4.2 7.3S14.6 11.6 14.6 8.2a4.2 4.2 0 118.4 0z"/>' +
|
||
'<circle class="ai-icon__accent-fill" cx="23" cy="8.2" r="1.45"/>',
|
||
hard:
|
||
'<path d="M16 6.2l10 4.8v10.2L16 26 6 21.2V11l10-4.8z"/>' +
|
||
'<path d="M16 6.2V26M6 11l10 4.8L26 11"/>' +
|
||
'<path class="ai-icon__accent" d="M11.5 14.6l4.5 2.2 4.5-2.2" stroke-width="2.2"/>' +
|
||
'<path class="ai-icon__accent-fill" d="M21.2 8.1l1.1 2.4 2.6.2-2 1.6.6 2.5-2.3-1.3-2.3 1.3.6-2.5-2-1.6 2.6-.2z"/>',
|
||
human:
|
||
'<circle cx="16" cy="11.2" r="4.3"/>' +
|
||
'<path d="M8.2 26.2c1.1-5.2 4.2-7.8 7.8-7.8s6.7 2.6 7.8 7.8"/>' +
|
||
'<path class="ai-icon__accent" d="M10 12.4c.2-4.6 2.8-7.4 6-7.4s5.8 2.8 6 7.4"/>' +
|
||
'<rect class="ai-icon__accent-fill" x="8" y="11.6" width="3.1" height="5" rx="1.5"/>' +
|
||
'<rect class="ai-icon__accent" x="20.9" y="11.6" width="3.1" height="5" rx="1.5"/>' +
|
||
'<path class="ai-icon__accent" d="M9.6 16.6v3.2h4.8"/>',
|
||
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"/>',
|
||
clearance:
|
||
'<rect x="6.5" y="4.5" width="14.5" height="18.5" rx="2.5"/>' +
|
||
'<path d="M10 10h7.5M10 14h7.5M10 18h4.5"/>' +
|
||
'<circle class="ai-icon__accent" cx="22.2" cy="22.2" r="5.6"/>' +
|
||
'<path class="ai-icon__accent" d="M19.6 22.4l1.7 1.7 3.6-3.8"/>',
|
||
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;
|
||
|
||
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(omsUrl(preferOmsH5() ? '/wechat/register' : '/register'));
|
||
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 ? '微信' : 'WeChat', 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 ? '要询价请先注册登记,或直接打电话、加微信。' : 'Register first to quote, or call / WeChat us directly.'
|
||
)
|
||
);
|
||
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 contactActions() {
|
||
const actions = el('div', 'ai-card__actions');
|
||
const tel = el('a', 'ai-card-link is-primary', PHONE);
|
||
tel.href = `tel:${PHONE}`;
|
||
const mail = el('a', 'ai-card-link', EMAIL);
|
||
mail.href = `mailto:${EMAIL}`;
|
||
const wx = el('span', 'ai-card-link', (lang === 'zh' ? '微信 ' : 'WeChat ') + WECHAT);
|
||
actions.appendChild(tel);
|
||
actions.appendChild(wx);
|
||
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__blobs" aria-hidden="true"></div>
|
||
<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>
|
||
<a class="ai-welcome-cta" href="#"></a>
|
||
</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-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">
|
||
<div class="ai-trust-bar__contacts">
|
||
<a class="ai-trust-phone" href="tel:${PHONE}">
|
||
<i class="bi bi-telephone" aria-hidden="true"></i>
|
||
<span class="ai-trust-label"></span>
|
||
</a>
|
||
<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>
|
||
<a class="ai-trust-email" href="mailto:${EMAIL}">
|
||
<i class="bi bi-envelope" aria-hidden="true"></i>
|
||
<span class="ai-trust-label"></span>
|
||
</a>
|
||
<a class="ai-trust-bar__human" href="#contact"></a>
|
||
</div>
|
||
<div class="ai-trust-bar__meta">
|
||
<p class="ai-trust-address">
|
||
<i class="bi bi-geo-alt" aria-hidden="true"></i>
|
||
<span class="ai-trust-label"></span>
|
||
</p>
|
||
<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>
|
||
</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>
|
||
<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-trust-wechat').addEventListener('click', async () => {
|
||
const btn = shell.querySelector('.ai-trust-wechat');
|
||
const label = btn.querySelector('.ai-trust-label');
|
||
try {
|
||
await navigator.clipboard.writeText(WECHAT);
|
||
if (label) label.textContent = lang === 'zh' ? '微信已复制' : 'WeChat copied';
|
||
} catch {
|
||
if (label) label.textContent = WECHAT;
|
||
}
|
||
window.setTimeout(() => {
|
||
if (label) label.textContent = t('trustWechat');
|
||
}, 1600);
|
||
});
|
||
|
||
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', () => {
|
||
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);
|
||
});
|
||
renderCoverage();
|
||
}
|
||
|
||
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;
|
||
}
|
||
syncActiveThreadMessages();
|
||
activeThreadId = threadId;
|
||
applyThreadMessages(getActiveThread());
|
||
saveThreads();
|
||
renderHistorySidebar();
|
||
renderMessages();
|
||
updateViewMode();
|
||
closeHistorySidebar();
|
||
inputEl.focus();
|
||
}
|
||
|
||
function startNewChat() {
|
||
if (isBusy) return;
|
||
syncActiveThreadMessages();
|
||
const current = getActiveThread();
|
||
if (current && current.messages.length === 0) {
|
||
closeHistorySidebar();
|
||
inputEl.focus();
|
||
return;
|
||
}
|
||
createThread(true);
|
||
renderHistorySidebar();
|
||
renderMessages();
|
||
updateViewMode();
|
||
closeHistorySidebar();
|
||
setNavActive('home');
|
||
inputEl.focus();
|
||
}
|
||
|
||
function toggleHistorySidebar() {
|
||
close9610Panel();
|
||
shell?.classList.toggle('ai-history-open');
|
||
}
|
||
|
||
function closeHistorySidebar() {
|
||
shell?.classList.remove('ai-history-open');
|
||
}
|
||
|
||
function open9610Panel() {
|
||
closeHistorySidebar();
|
||
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 closeDrawers() {
|
||
closeHistorySidebar();
|
||
close9610Panel();
|
||
}
|
||
|
||
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');
|
||
applyWelcomeTitle();
|
||
renderWelcomeSub();
|
||
const heroCta = shell.querySelector('.ai-welcome-cta');
|
||
if (heroCta) {
|
||
heroCta.textContent = t('heroCta');
|
||
heroCta.href = registerUrl();
|
||
}
|
||
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-phone', t('trustPhone'));
|
||
setTrustLabel('.ai-trust-address', t('trustAddress'));
|
||
setTrustLabel('.ai-trust-wechat', t('trustWechat'));
|
||
setTrustLabel('.ai-trust-email', t('trustEmail'));
|
||
setTrustLabel('.ai-trust-icp', t('trustIcp'));
|
||
shell.querySelector('.ai-trust-bar__human').textContent = t('human') + ' →';
|
||
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();
|
||
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|退税|专票|免税|疑难|小批量|不接/.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 ? '直接打给我们或加微信。询价请先注册登记。' : 'Call or WeChat 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 = { id: 'hard', step: 0, data: {} };
|
||
return {
|
||
text: zh
|
||
? '别人不接的单我们接。如果走 9610,先确认一张票,再给你对应方案。'
|
||
: 'We take orders others decline. For 9610, 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').forEach((node) => {
|
||
node.disabled = busy;
|
||
});
|
||
syncComposerSendState();
|
||
}
|
||
|
||
function onIntentClick(intent) {
|
||
if (isBusy) return;
|
||
sendUserMessage(intentCopy(intent).label, { intentId: intent.id });
|
||
}
|
||
|
||
function sendUserMessage(text, extra) {
|
||
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 a human 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();
|
||
});
|
||
});
|
||
|
||
shell?.querySelector('.ai-trust-bar__human')?.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
openNavTopic('contact');
|
||
});
|
||
}
|
||
|
||
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') closeDrawers();
|
||
});
|
||
|
||
const bootTopic = (location.hash || '').replace('#', '');
|
||
if (bootTopic === 'services' || bootTopic === '9610') {
|
||
open9610Panel();
|
||
} else if (NAV_TOPICS[bootTopic]) {
|
||
openNavTopic(bootTopic);
|
||
}
|
||
}
|
||
|
||
window.IDEAdvisor = {
|
||
setLanguage(nextLang) {
|
||
lang = nextLang === 'zh' ? 'zh' : 'en';
|
||
renderStaticCopy();
|
||
},
|
||
open: () => {
|
||
if (!isHomePage()) window.location.href = 'index.html';
|
||
},
|
||
close: () => closeDrawers(),
|
||
};
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|