2026-08-14 19:26:39 +08:00
|
|
|
|
import { detectVisitorCountry, translationProvider } from "./visitor-region.js?v=20260814f";
|
|
|
|
|
|
import { renderActivities } from "./render-intelligence-activities.js?v=20260814e";
|
|
|
|
|
|
import { state } from "./render-intelligence-state.js?v=20260814e";
|
|
|
|
|
|
|
|
|
|
|
|
/** 家纺跨境情报前端渲染器。用户文案集中在 TEXT,便于后续接入完整 i18n。 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const TEXT = {
|
|
|
|
|
|
tabs: {
|
|
|
|
|
|
today: "今日必看",
|
|
|
|
|
|
standards: "趋势雷达",
|
|
|
|
|
|
calendar: "备货日历",
|
|
|
|
|
|
exhibitions: "展会通",
|
|
|
|
|
|
policies: "政策预警",
|
|
|
|
|
|
activities: "跨境活动",
|
|
|
|
|
|
platforms: "平台渠道",
|
|
|
|
|
|
},
|
|
|
|
|
|
allRegions: "全部区域",
|
|
|
|
|
|
searchPlaceholder: "搜索国家、展会、政策或产品机会",
|
|
|
|
|
|
sourceLink: "查看来源页",
|
|
|
|
|
|
loadMore: "加载更多",
|
|
|
|
|
|
empty: "当前筛选条件下没有情报",
|
|
|
|
|
|
verified: "已核验",
|
|
|
|
|
|
officialRegistration: "官方报名",
|
|
|
|
|
|
exhibitorInfo: "参展信息",
|
|
|
|
|
|
visitorRegistration: "观众登记",
|
|
|
|
|
|
translatedVersion: "查看译文版",
|
|
|
|
|
|
shareItem: "转发给同事",
|
|
|
|
|
|
calendar: {
|
|
|
|
|
|
important: "重点商机",
|
|
|
|
|
|
all: "全部节点",
|
|
|
|
|
|
allMarkets: "全部市场区域",
|
|
|
|
|
|
allCountries: "全部国家/地区",
|
|
|
|
|
|
allProducts: "全部产品方向",
|
|
|
|
|
|
allPlatforms: "全部销售平台",
|
|
|
|
|
|
platformFilterHint: "只显示该平台已有明确经营动作的节日与消费节点;不代表平台官方活动日历。",
|
|
|
|
|
|
what: "这个节日是什么",
|
|
|
|
|
|
markets: "影响国家/地区",
|
|
|
|
|
|
languages: "主要语种",
|
|
|
|
|
|
platforms: "重点销售渠道",
|
|
|
|
|
|
platformActions: "平台怎么做",
|
|
|
|
|
|
opportunity: "家纺与纺织机会",
|
|
|
|
|
|
design: "设计与文化提醒",
|
|
|
|
|
|
next: "现在该做什么",
|
|
|
|
|
|
facts: "官方事实",
|
|
|
|
|
|
analysis: "系统经营分析",
|
|
|
|
|
|
cultureReference: "了解节日文化",
|
|
|
|
|
|
visualReference: "Bing 找节日照片",
|
|
|
|
|
|
addCalendar: "下载日历提醒(ICS)",
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const SECTION_LABELS = {
|
|
|
|
|
|
calendar: "备货日历",
|
|
|
|
|
|
exhibition: "展会通",
|
|
|
|
|
|
policy: "政策预警",
|
|
|
|
|
|
standard: "趋势雷达",
|
|
|
|
|
|
platform: "平台渠道",
|
|
|
|
|
|
activity: "跨境活动",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const CALENDAR_PRODUCT_RULES = [
|
|
|
|
|
|
["床品", /床品|四件套|被|床单|枕套|床垫|睡袋/],
|
|
|
|
|
|
["毛巾浴巾", /毛巾|浴巾|浴袍/],
|
|
|
|
|
|
["毯类", /毯|盖毯/],
|
|
|
|
|
|
["窗帘软装", /窗帘|抱枕|靠垫|软装|地毯/],
|
|
|
|
|
|
["餐桌厨房", /桌布|餐垫|餐桌|厨房/],
|
|
|
|
|
|
["酒店布草", /酒店|民宿|布草/],
|
|
|
|
|
|
["婴童用品", /婴|儿童|宝宝|护理/],
|
|
|
|
|
|
["面料与功能", /面料|凉感|防晒|阻燃|防水|功能/],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const PLATFORM_FAMILIES = ["Amazon", "Walmart", "TikTok Shop", "Temu", "Shopee", "Lazada", "Mercado Libre", "Ozon", "Wildberries", "Coupang", "乐天市场", "OTTO", "Cdiscount", "Etsy", "eBay", "bol.com", "Kaufland", "Shopify", "Alibaba.com", "SHEIN"];
|
|
|
|
|
|
const PLATFORM_LOGOS = {
|
|
|
|
|
|
Amazon: "amazon", Walmart: "walmart", "TikTok Shop": "tiktok", Temu: "temu",
|
|
|
|
|
|
Shopee: "shopee", Lazada: "lazada", "Mercado Libre": "mercado-libre", Ozon: "ozon",
|
|
|
|
|
|
Wildberries: "wildberries", Coupang: "coupang", 乐天市场: "rakuten", OTTO: "otto",
|
|
|
|
|
|
Cdiscount: "cdiscount", Etsy: "etsy", eBay: "ebay", "bol.com": "bol",
|
|
|
|
|
|
Kaufland: "kaufland", Shopify: "shopify", "Alibaba.com": "alibaba", SHEIN: "shein",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function platformFamily(value) {
|
|
|
|
|
|
return PLATFORM_FAMILIES.find((family) => value === family || value.startsWith(`${family} `)) || value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makePlatformIdentity(name) {
|
|
|
|
|
|
const identity = create("strong", "calendar-platform-identity");
|
|
|
|
|
|
const logoKey = PLATFORM_LOGOS[platformFamily(name)];
|
|
|
|
|
|
if (logoKey) {
|
|
|
|
|
|
const logo = create("img", "calendar-platform-logo");
|
|
|
|
|
|
logo.src = `./assets/platform-logos/${logoKey}.svg`;
|
|
|
|
|
|
logo.alt = "";
|
|
|
|
|
|
logo.width = 24;
|
|
|
|
|
|
logo.height = 24;
|
|
|
|
|
|
identity.appendChild(logo);
|
|
|
|
|
|
}
|
|
|
|
|
|
identity.appendChild(create("span", "", name));
|
|
|
|
|
|
return identity;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function calendarPlatformFamilies(item) {
|
|
|
|
|
|
return [...new Set((item.platforms || []).map(platformFamily))];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function create(tag, className = "", text = "") {
|
|
|
|
|
|
const node = document.createElement(tag);
|
|
|
|
|
|
if (className) node.className = className;
|
|
|
|
|
|
if (text) node.textContent = text;
|
|
|
|
|
|
return node;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openLightbox(src, alt = "放大图片", { previewSrc = "" } = {}) {
|
|
|
|
|
|
const previousFocus = document.activeElement;
|
|
|
|
|
|
const overlay = create("div", "lightbox-overlay lightbox-brand-sansheng");
|
|
|
|
|
|
overlay.setAttribute("role", "dialog");
|
|
|
|
|
|
overlay.setAttribute("aria-modal", "true");
|
|
|
|
|
|
overlay.setAttribute("aria-label", alt);
|
|
|
|
|
|
const content = create("div", "lightbox-content-wrapper");
|
|
|
|
|
|
const stage = create("div", "lightbox-stage");
|
|
|
|
|
|
const close = create("button", "lightbox-close", "×");
|
|
|
|
|
|
close.type = "button";
|
|
|
|
|
|
close.setAttribute("aria-label", "关闭图片预览");
|
|
|
|
|
|
|
|
|
|
|
|
if (previewSrc) {
|
|
|
|
|
|
const preview = create("img", "lightbox-preview");
|
|
|
|
|
|
preview.src = previewSrc;
|
|
|
|
|
|
preview.alt = "";
|
|
|
|
|
|
preview.referrerPolicy = "no-referrer";
|
|
|
|
|
|
stage.appendChild(preview);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loader = create("div", "lightbox-loader");
|
|
|
|
|
|
loader.setAttribute("role", "status");
|
|
|
|
|
|
loader.setAttribute("aria-live", "polite");
|
|
|
|
|
|
const logoWrap = create("span", "lightbox-loader-logo-wrap");
|
|
|
|
|
|
const logo = create("img", "lightbox-loader-logo");
|
|
|
|
|
|
logo.src = "./assets/logo.svg";
|
|
|
|
|
|
logo.alt = "";
|
|
|
|
|
|
logo.setAttribute("aria-hidden", "true");
|
|
|
|
|
|
logoWrap.appendChild(logo);
|
|
|
|
|
|
const status = create("span", "lightbox-loader-status", "正在加载高清原图");
|
|
|
|
|
|
const retry = create("button", "lightbox-retry", "重新加载");
|
|
|
|
|
|
retry.type = "button";
|
|
|
|
|
|
retry.hidden = true;
|
|
|
|
|
|
loader.append(logoWrap, status, retry);
|
|
|
|
|
|
stage.appendChild(loader);
|
|
|
|
|
|
|
|
|
|
|
|
const footer = create("div", "lightbox-footer");
|
|
|
|
|
|
const downloadLink = create("a", "lightbox-download");
|
|
|
|
|
|
downloadLink.href = src;
|
|
|
|
|
|
downloadLink.target = "_blank";
|
|
|
|
|
|
downloadLink.rel = "noopener noreferrer";
|
|
|
|
|
|
downloadLink.download = "";
|
|
|
|
|
|
downloadLink.setAttribute("aria-label", `下载《${alt}》原图`);
|
|
|
|
|
|
downloadLink.innerHTML = '<span aria-hidden="true">↓</span><span>下载原图</span>';
|
|
|
|
|
|
downloadLink.addEventListener("click", async (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
if (downloadLink.classList.contains("is-downloading")) return;
|
|
|
|
|
|
const label = downloadLink.lastElementChild;
|
|
|
|
|
|
downloadLink.classList.add("is-downloading");
|
|
|
|
|
|
downloadLink.setAttribute("aria-busy", "true");
|
|
|
|
|
|
label.textContent = "准备下载…";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await fetch(src, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer" });
|
|
|
|
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
|
|
|
|
const blobUrl = URL.createObjectURL(await response.blob());
|
|
|
|
|
|
const save = create("a");
|
|
|
|
|
|
save.href = blobUrl;
|
|
|
|
|
|
save.download = decodeURIComponent(new URL(src, document.baseURI).pathname.split("/").pop() || "image")
|
|
|
|
|
|
.replace(/[\\/:*?"<>|]/g, "-");
|
|
|
|
|
|
document.body.appendChild(save);
|
|
|
|
|
|
save.click();
|
|
|
|
|
|
save.remove();
|
|
|
|
|
|
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("原图下载失败,已打开原图地址", error);
|
|
|
|
|
|
window.open(src, "_blank", "noopener,noreferrer");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
downloadLink.classList.remove("is-downloading");
|
|
|
|
|
|
downloadLink.removeAttribute("aria-busy");
|
|
|
|
|
|
label.textContent = "下载原图";
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
footer.appendChild(downloadLink);
|
|
|
|
|
|
|
|
|
|
|
|
content.append(close, stage, footer);
|
|
|
|
|
|
overlay.appendChild(content);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
|
|
|
|
|
|
let loadId = 0;
|
|
|
|
|
|
const loadOriginal = () => {
|
|
|
|
|
|
const currentLoad = ++loadId;
|
|
|
|
|
|
overlay.classList.remove("is-loaded", "is-error");
|
|
|
|
|
|
status.textContent = "正在加载高清原图";
|
|
|
|
|
|
retry.hidden = true;
|
|
|
|
|
|
|
|
|
|
|
|
const image = create("img", "lightbox-img");
|
|
|
|
|
|
image.alt = alt;
|
|
|
|
|
|
image.decoding = "async";
|
|
|
|
|
|
image.referrerPolicy = "no-referrer";
|
|
|
|
|
|
stage.insertBefore(image, loader);
|
|
|
|
|
|
|
|
|
|
|
|
image.addEventListener("load", async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await image.decode();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!image.naturalWidth) return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (currentLoad !== loadId || !overlay.isConnected) return;
|
|
|
|
|
|
stage.querySelectorAll(".lightbox-img").forEach((node) => {
|
|
|
|
|
|
if (node !== image) node.remove();
|
|
|
|
|
|
});
|
|
|
|
|
|
requestAnimationFrame(() => overlay.classList.add("is-loaded"));
|
|
|
|
|
|
}, { once: true });
|
|
|
|
|
|
|
|
|
|
|
|
image.addEventListener("error", () => {
|
|
|
|
|
|
if (currentLoad !== loadId || !overlay.isConnected) return;
|
|
|
|
|
|
image.remove();
|
|
|
|
|
|
status.textContent = previewSrc ? "高清原图加载失败,当前显示预览图" : "高清原图加载失败";
|
|
|
|
|
|
retry.hidden = false;
|
|
|
|
|
|
overlay.classList.add("is-error");
|
|
|
|
|
|
}, { once: true });
|
|
|
|
|
|
|
|
|
|
|
|
image.src = src;
|
|
|
|
|
|
};
|
|
|
|
|
|
retry.addEventListener("click", loadOriginal);
|
|
|
|
|
|
loadOriginal();
|
|
|
|
|
|
|
|
|
|
|
|
const closeLightbox = () => {
|
|
|
|
|
|
document.removeEventListener("keydown", handleEscape);
|
|
|
|
|
|
overlay.classList.remove("active");
|
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
|
overlay.remove();
|
|
|
|
|
|
previousFocus?.focus?.();
|
|
|
|
|
|
}, 180);
|
|
|
|
|
|
};
|
|
|
|
|
|
const handleEscape = (event) => {
|
|
|
|
|
|
if (event.key === "Escape") closeLightbox();
|
|
|
|
|
|
};
|
|
|
|
|
|
overlay.addEventListener("click", (event) => {
|
|
|
|
|
|
if (event.target === overlay || event.target === close) closeLightbox();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener("keydown", handleEscape);
|
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
|
overlay.classList.add("active");
|
|
|
|
|
|
close.focus();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sectionItems(data) {
|
|
|
|
|
|
if (!data) return [];
|
|
|
|
|
|
if (state.activeSection === "today") return Array.isArray(data.today) ? data.today : [];
|
|
|
|
|
|
if (state.activeSection === "calendar") return Array.isArray(data.calendar_insights) ? data.calendar_insights : (data.calendar || []);
|
|
|
|
|
|
if (state.activeSection === "standards") return [...(data.standards || []), ...trendItems(data)];
|
|
|
|
|
|
return Array.isArray(data[state.activeSection]) ? data[state.activeSection] : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trendItems(data) {
|
|
|
|
|
|
return Object.values(data?.trends?.categories || {}).flatMap((category) => category.items || []);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalized(value) {
|
|
|
|
|
|
return String(value || "").trim().toLowerCase();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function matchesFilters(item) {
|
|
|
|
|
|
const itemRegions = [...(item.affected_regions || []), ...(item.affected_countries || [])];
|
|
|
|
|
|
if (state.region && item.region !== state.region && !itemRegions.includes(state.region)) return false;
|
|
|
|
|
|
if (!state.query) return true;
|
|
|
|
|
|
const haystack = normalized([
|
|
|
|
|
|
item.title,
|
|
|
|
|
|
item.title_original,
|
|
|
|
|
|
item.summary,
|
|
|
|
|
|
item.source,
|
|
|
|
|
|
item.region,
|
|
|
|
|
|
item.opportunity,
|
|
|
|
|
|
item.action,
|
|
|
|
|
|
item.policy_type,
|
|
|
|
|
|
item.what_it_is,
|
|
|
|
|
|
(item.affected_markets || []).join(" "),
|
|
|
|
|
|
(item.affected_countries || []).join(" "),
|
|
|
|
|
|
(item.languages || []).join(" "),
|
|
|
|
|
|
(item.platforms || []).join(" "),
|
|
|
|
|
|
(item.product_suggestions || []).join(" "),
|
|
|
|
|
|
(item.design_opportunities || []).join(" "),
|
|
|
|
|
|
(item.event_focus || []).join(" "),
|
|
|
|
|
|
(item.markets || []).join(" "),
|
|
|
|
|
|
(item.fit_sellers || []).join(" "),
|
|
|
|
|
|
item.cross_border,
|
|
|
|
|
|
item.textile_category,
|
|
|
|
|
|
item.category,
|
|
|
|
|
|
item.category_key,
|
|
|
|
|
|
item.activity_type,
|
|
|
|
|
|
item.location,
|
|
|
|
|
|
item.relevance,
|
|
|
|
|
|
].join(" "));
|
|
|
|
|
|
return haystack.includes(normalized(state.query));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatDate(item) {
|
|
|
|
|
|
const start = String(item.start_date || item.published_at || "").slice(0, 10);
|
|
|
|
|
|
const end = String(item.end_date || "").slice(0, 10);
|
|
|
|
|
|
if (start && end && start !== end) return `${start} 至 ${end}`;
|
|
|
|
|
|
return start || "持续监测";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatVerified(value) {
|
|
|
|
|
|
const raw = String(value || "");
|
|
|
|
|
|
if (!raw) return "";
|
|
|
|
|
|
return raw.slice(0, 16).replace("T", " ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sectionOf(item) {
|
|
|
|
|
|
if (item.section === "exhibition") return "exhibitions";
|
|
|
|
|
|
if (item.section === "policy") return "policies";
|
|
|
|
|
|
if (item.section === "standard") return "standards";
|
|
|
|
|
|
if (item.section === "activity") return "activities";
|
|
|
|
|
|
if (item.section === "platform") return "platforms";
|
|
|
|
|
|
return "calendar";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeVisual(item) {
|
|
|
|
|
|
const visual = create("div", "intelligence-card-visual");
|
|
|
|
|
|
if (item.image) {
|
|
|
|
|
|
const img = create("img", "intelligence-card-image");
|
|
|
|
|
|
img.src = item.image;
|
|
|
|
|
|
img.alt = item.title || "家纺情报配图";
|
|
|
|
|
|
img.loading = "lazy";
|
|
|
|
|
|
img.referrerPolicy = "no-referrer";
|
|
|
|
|
|
const usePoster = () => {
|
|
|
|
|
|
visual.classList.add("is-poster");
|
|
|
|
|
|
img.remove();
|
|
|
|
|
|
};
|
|
|
|
|
|
img.addEventListener("load", () => {
|
|
|
|
|
|
if (img.naturalWidth < 320 || img.naturalHeight < 180) usePoster();
|
|
|
|
|
|
});
|
|
|
|
|
|
img.addEventListener("error", usePoster);
|
|
|
|
|
|
visual.appendChild(img);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
visual.classList.add("is-poster");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const poster = item.poster || {};
|
|
|
|
|
|
visual.style.setProperty("--poster-accent", poster.accent || "#0051d6");
|
|
|
|
|
|
visual.style.setProperty("--poster-ink", poster.ink || "#102a43");
|
|
|
|
|
|
visual.style.setProperty("--poster-paper", poster.paper || "#eef7ff");
|
|
|
|
|
|
|
|
|
|
|
|
const overlay = create("div", "intelligence-poster-copy");
|
|
|
|
|
|
overlay.append(
|
|
|
|
|
|
create("span", "intelligence-poster-eyebrow", poster.eyebrow || SECTION_LABELS[item.section] || "家纺跨境情报"),
|
|
|
|
|
|
create("strong", "intelligence-poster-title", item.title || "家纺情报"),
|
|
|
|
|
|
create("span", "intelligence-poster-state.region", item.region || "全球"),
|
|
|
|
|
|
);
|
|
|
|
|
|
visual.appendChild(overlay);
|
|
|
|
|
|
return visual;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeBadge(text, className = "") {
|
|
|
|
|
|
return create("span", `intelligence-badge ${className}`.trim(), text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeExternalLink(label, href, className = "intelligence-source-link") {
|
|
|
|
|
|
const link = create("a", className, label);
|
|
|
|
|
|
link.href = href || "#";
|
|
|
|
|
|
link.target = "_blank";
|
|
|
|
|
|
link.rel = "noopener noreferrer nofollow";
|
|
|
|
|
|
if (/(?:wikipedia|wikimedia)\.org/i.test(href || "")) {
|
|
|
|
|
|
link.classList.add("requires-cross-border-network");
|
|
|
|
|
|
link.title = "请注意:目标网址可能需要跨境网络才能访问";
|
|
|
|
|
|
link.appendChild(create("span", "external-network-badge", "需跨境网络"));
|
|
|
|
|
|
}
|
|
|
|
|
|
return link;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sourceLinkLabel(item) {
|
|
|
|
|
|
const authority = String(item.authority || "");
|
|
|
|
|
|
if (item.source_id === "calendar_tmall") return "打开规则检索入口";
|
|
|
|
|
|
if (authority.includes("结构化公共假日数据")) return "查看日期数据";
|
|
|
|
|
|
if (authority.includes("结构化宗教历法数据")) return "查看历法数据";
|
|
|
|
|
|
if (authority.includes("认证目录")) return "查看认证目录";
|
|
|
|
|
|
if (/官方|政府|主办方/.test(authority)) return "查看官方来源";
|
|
|
|
|
|
return TEXT.sourceLink;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function currentTranslationProvider() {
|
|
|
|
|
|
return state.viewerCountryResolved ? translationProvider(state.viewerCountryCode) : "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function preferredTranslation(item, prefix = "") {
|
|
|
|
|
|
const provider = currentTranslationProvider();
|
|
|
|
|
|
if (!provider) return { url: "", isFallback: false };
|
|
|
|
|
|
const key = (name) => prefix ? `${prefix}_${name}` : name;
|
|
|
|
|
|
const googleUrl = item[key("translation_url")] || "";
|
|
|
|
|
|
const yandexUrl = item[key("translation_fallback_url")] || "";
|
|
|
|
|
|
const url = provider === "yandex" ? (yandexUrl || googleUrl) : (googleUrl || yandexUrl);
|
|
|
|
|
|
return { url, isFallback: Boolean(url && url === yandexUrl) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cultureReferenceUrl(item) {
|
|
|
|
|
|
if (!state.viewerCountryResolved) return "";
|
|
|
|
|
|
return state.viewerCountryCode === "CN"
|
|
|
|
|
|
? (item.culture_reference_cn_url || item.culture_reference_url || "")
|
|
|
|
|
|
: (item.culture_reference_url || "");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function detectViewerCountry() {
|
|
|
|
|
|
if (state.viewerCountryResolved) return Promise.resolve(state.viewerCountryCode);
|
|
|
|
|
|
return detectVisitorCountry()
|
|
|
|
|
|
.then((countryCode) => {
|
|
|
|
|
|
state.viewerCountryCode = countryCode;
|
|
|
|
|
|
state.viewerCountryResolved = true;
|
|
|
|
|
|
state.activeIntelligenceRerender?.();
|
|
|
|
|
|
return state.viewerCountryCode;
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
state.viewerCountryResolved = true;
|
|
|
|
|
|
state.activeIntelligenceRerender?.();
|
|
|
|
|
|
return "";
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendTranslationLinks(actions, item, className = "intelligence-source-link policy-translate") {
|
|
|
|
|
|
if (!item.needs_translation) return;
|
|
|
|
|
|
const translation = preferredTranslation(item);
|
|
|
|
|
|
if (translation.url) actions.appendChild(makeExternalLink(TEXT.translatedVersion, translation.url, `${className}${translation.isFallback ? " is-fallback" : ""}`));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendPrefixedTranslationLink(actions, item, prefix, className = "platform-action translate") {
|
|
|
|
|
|
if (!item[`${prefix}_needs_translation`]) return;
|
|
|
|
|
|
const translation = preferredTranslation(item, prefix);
|
2026-08-14 22:18:51 +08:00
|
|
|
|
if (translation.url) actions.appendChild(makeExternalLink("辅助翻译入驻说明", translation.url, `${className}${translation.isFallback ? " is-fallback" : ""}`));
|
2026-08-14 19:26:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makePlatformActionGroup(label, url, item, prefix, className = "platform-action") {
|
|
|
|
|
|
const group = create("span", "platform-action-group translation-action-group");
|
|
|
|
|
|
group.appendChild(makeExternalLink(label, url, className));
|
|
|
|
|
|
const actions = group;
|
|
|
|
|
|
if (prefix === "seller") appendPrefixedTranslationLink(actions, item, "seller");
|
|
|
|
|
|
return group;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function itemShareUrl(item) {
|
|
|
|
|
|
const section = sectionOf(item);
|
|
|
|
|
|
return new URL(`/textile/intelligence/${section}/${item.id}/`, window.location.origin).href;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function shareItem(item) {
|
|
|
|
|
|
const shareData = {
|
|
|
|
|
|
title: `${item.title}|家纺跨境情报`,
|
|
|
|
|
|
text: item.summary || item.opportunity || "家纺跨境机会情报",
|
|
|
|
|
|
url: itemShareUrl(item),
|
|
|
|
|
|
};
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (navigator.share) {
|
|
|
|
|
|
await navigator.share(shareData);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await navigator.clipboard.writeText(shareData.url);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error?.name !== "AbortError") console.error("[家纺跨境情报] 单条分享失败:", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeShareButton(item) {
|
|
|
|
|
|
const button = create("button", "intelligence-item-share is-tertiary");
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.setAttribute("aria-label", `分享${item.title}`);
|
|
|
|
|
|
button.title = TEXT.shareItem;
|
|
|
|
|
|
button.innerHTML = '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3v11m0-11 4 4m-4-4L8 7M5 11v8h14v-8"/></svg>';
|
|
|
|
|
|
button.addEventListener("click", () => shareItem(item));
|
|
|
|
|
|
return button;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCard(item) {
|
|
|
|
|
|
const article = create("article", `intelligence-card is-${sectionOf(item)}`);
|
|
|
|
|
|
article.appendChild(makeVisual(item));
|
|
|
|
|
|
|
|
|
|
|
|
const body = create("div", "intelligence-card-body");
|
|
|
|
|
|
const meta = create("div", "intelligence-card-meta");
|
|
|
|
|
|
meta.appendChild(makeBadge(SECTION_LABELS[item.section] || "情报", `badge-${sectionOf(item)}`));
|
|
|
|
|
|
if (item.risk_level) meta.appendChild(makeBadge(`${item.risk_level}风险`, `risk-${item.risk_level}`));
|
|
|
|
|
|
if (item.impact) meta.appendChild(makeBadge(`${item.impact}影响`, `impact-${item.impact}`));
|
|
|
|
|
|
meta.appendChild(create("span", "intelligence-card-date", formatDate(item)));
|
|
|
|
|
|
|
|
|
|
|
|
const title = create("h3", "intelligence-card-title", item.title || "未命名情报");
|
|
|
|
|
|
const summary = create("p", "intelligence-card-summary", item.summary || "");
|
|
|
|
|
|
|
|
|
|
|
|
body.append(meta, title);
|
|
|
|
|
|
if (item.title_original && item.title_original !== item.title) {
|
|
|
|
|
|
body.appendChild(create("p", "intelligence-card-original", item.title_original));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (summary.textContent) body.appendChild(summary);
|
|
|
|
|
|
|
|
|
|
|
|
const relevance = create("div", "intelligence-card-relevance");
|
|
|
|
|
|
const affected = item.affected_countries || item.affected_markets || [item.region].filter(Boolean);
|
|
|
|
|
|
const targets = item.product_suggestions || item.affected_products || item.event_focus || item.product_targets || [];
|
|
|
|
|
|
relevance.append(
|
|
|
|
|
|
create("span", "", affected.length ? `影响:${compactList(affected, 3)}` : `区域:${item.region || "全球"}`),
|
|
|
|
|
|
create("span", "", targets.length ? `重点:${compactList(targets, 3)}` : "重点:打开详情核对适用商品"),
|
|
|
|
|
|
);
|
|
|
|
|
|
body.appendChild(relevance);
|
|
|
|
|
|
|
|
|
|
|
|
const action = item.today_action || item.next_action || item.opportunity || item.action || item.application;
|
|
|
|
|
|
if (action) {
|
|
|
|
|
|
const actionBox = create("div", "intelligence-card-action");
|
|
|
|
|
|
actionBox.append(create("span", "intelligence-card-action-label", "现在要做"), create("p", "", action));
|
|
|
|
|
|
body.appendChild(actionBox);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const footer = create("div", "intelligence-card-footer");
|
|
|
|
|
|
const proof = create("div", "intelligence-card-proof");
|
|
|
|
|
|
proof.append(
|
|
|
|
|
|
create("strong", "", item.source || "权威来源"),
|
|
|
|
|
|
create("span", "", `${item.authority || TEXT.verified} · ${formatVerified(item.verified_at)}`),
|
|
|
|
|
|
);
|
|
|
|
|
|
const link = makeExternalLink(sourceLinkLabel(item), item.url || item.source_url);
|
|
|
|
|
|
link.setAttribute("aria-label", `打开${item.source || "情报"}来源`);
|
|
|
|
|
|
const footerActions = create("div", "intelligence-card-footer-actions");
|
|
|
|
|
|
footerActions.append(makeShareButton(item));
|
|
|
|
|
|
appendTranslationLinks(footerActions, item);
|
|
|
|
|
|
footerActions.appendChild(link);
|
|
|
|
|
|
footer.append(proof, footerActions);
|
|
|
|
|
|
body.appendChild(footer);
|
|
|
|
|
|
article.appendChild(body);
|
|
|
|
|
|
return article;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function appendTags(container, values, className = "intelligence-tag") {
|
|
|
|
|
|
(values || []).forEach((value) => container.appendChild(create("span", className, value)));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatMonthDay(value) {
|
|
|
|
|
|
const raw = String(value || "").slice(0, 10);
|
|
|
|
|
|
if (!raw) return { year: "", month: "", day: "", weekday: "" };
|
|
|
|
|
|
const parsed = new Date(`${raw}T00:00:00`);
|
|
|
|
|
|
return {
|
|
|
|
|
|
year: String(parsed.getFullYear()),
|
|
|
|
|
|
month: `${parsed.getMonth() + 1}月`,
|
|
|
|
|
|
day: String(parsed.getDate()).padStart(2, "0"),
|
|
|
|
|
|
weekday: new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(parsed),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 19:17:16 +08:00
|
|
|
|
export function calendarTodayIso(now = new Date()) {
|
|
|
|
|
|
return now.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function sortCalendarItems(items, now = new Date()) {
|
|
|
|
|
|
const today = calendarTodayIso(now);
|
|
|
|
|
|
return [...items].sort((a, b) => {
|
|
|
|
|
|
const dateA = String(a.start_date || "").slice(0, 10);
|
|
|
|
|
|
const dateB = String(b.start_date || "").slice(0, 10);
|
|
|
|
|
|
const pastOrder = Number(dateA < today) - Number(dateB < today);
|
|
|
|
|
|
if (pastOrder) return pastOrder;
|
|
|
|
|
|
const dateOrder = dateA < today && dateB < today ? dateB.localeCompare(dateA) : dateA.localeCompare(dateB);
|
|
|
|
|
|
return dateOrder || Number(b.priority_score || 0) - Number(a.priority_score || 0);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function calendarDefaultItems(items, year, now = new Date()) {
|
|
|
|
|
|
const today = calendarTodayIso(now);
|
|
|
|
|
|
if (Number(year) !== Number(today.slice(0, 4))) return [...items];
|
|
|
|
|
|
return items.filter((item) => String(item.start_date || "").slice(0, 10) >= today);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 19:26:39 +08:00
|
|
|
|
function compactList(values, limit = 5) {
|
|
|
|
|
|
const unique = [...new Set((values || []).filter(Boolean))];
|
|
|
|
|
|
if (unique.length <= limit) return unique.join("、") || "暂无专项信息";
|
|
|
|
|
|
return `${unique.slice(0, limit).join("、")} 等 ${unique.length} 项`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function calendarProductCategories(item) {
|
|
|
|
|
|
const haystack = (item.product_suggestions || []).join(" ");
|
|
|
|
|
|
const matched = CALENDAR_PRODUCT_RULES.filter(([, pattern]) => pattern.test(haystack)).map(([label]) => label);
|
|
|
|
|
|
return matched.length ? matched : ["其他产品"];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeIcs(value) {
|
|
|
|
|
|
return String(value || "").replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function nextDateValue(value) {
|
|
|
|
|
|
const parsed = new Date(`${value}T00:00:00Z`);
|
|
|
|
|
|
parsed.setUTCDate(parsed.getUTCDate() + 1);
|
|
|
|
|
|
return parsed.toISOString().slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCalendarDownloadButton(item) {
|
|
|
|
|
|
const button = create("button", "intelligence-item-share calendar-download", TEXT.calendar.addCalendar);
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const start = String(item.start_date || "").replace(/-/g, "");
|
|
|
|
|
|
const end = nextDateValue(item.end_date || item.start_date).replace(/-/g, "");
|
|
|
|
|
|
const description = `${item.what_it_is || item.summary || ""}\n备货窗口:${item.stocking_start || ""} 至 ${item.stocking_end || ""}\n行动:${item.next_action || item.opportunity || ""}`;
|
|
|
|
|
|
const content = [
|
|
|
|
|
|
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//AI News Radar//Textile Intelligence//ZH-CN",
|
|
|
|
|
|
"BEGIN:VEVENT", `UID:${escapeIcs(item.id)}@ai-news-radar`, `DTSTART;VALUE=DATE:${start}`,
|
|
|
|
|
|
`DTEND;VALUE=DATE:${end}`, `SUMMARY:${escapeIcs(item.title)}`, `DESCRIPTION:${escapeIcs(description)}`,
|
|
|
|
|
|
`URL:${escapeIcs(item.url || item.source_url || "")}`, "END:VEVENT", "END:VCALENDAR",
|
|
|
|
|
|
].join("\r\n");
|
|
|
|
|
|
const href = URL.createObjectURL(new Blob([content], { type: "text/calendar;charset=utf-8" }));
|
|
|
|
|
|
const link = create("a");
|
|
|
|
|
|
link.href = href;
|
|
|
|
|
|
link.download = `${item.start_date || "calendar"}-${String(item.title || "备货节点").replace(/[\\/:*?\"<>|]/g, "-")}.ics`;
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
URL.revokeObjectURL(href);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("生成备货日历文件失败", error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
return button;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function calendarScopeText(item) {
|
|
|
|
|
|
const details = item.country_details || [];
|
|
|
|
|
|
if (!details.length) return "以官方来源公布范围为准";
|
|
|
|
|
|
const regional = details.filter((detail) => detail.scope && detail.scope !== "全国适用" && !detail.scope.includes("全境"));
|
|
|
|
|
|
if (!regional.length) return `${details.length} 个国家/地区均为全国适用`;
|
|
|
|
|
|
const nationalCount = details.length - regional.length;
|
|
|
|
|
|
const regionalText = regional.map((detail) => `${detail.country}:${detail.scope}`).join(";");
|
|
|
|
|
|
return `${nationalCount ? `${nationalCount} 国全国适用;` : ""}${regionalText}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCalendarFact(label, values) {
|
|
|
|
|
|
const fact = create("div", "calendar-fact");
|
|
|
|
|
|
fact.append(create("span", "", label), create("strong", "", compactList(values)));
|
|
|
|
|
|
return fact;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeLocalHolidayNames(item) {
|
|
|
|
|
|
const details = (item.country_details || [])
|
|
|
|
|
|
.filter((detail) => detail.local_name)
|
|
|
|
|
|
.sort((a, b) => Number(b.country === state.calendarCountry) - Number(a.country === state.calendarCountry));
|
|
|
|
|
|
if (!details.length) return null;
|
|
|
|
|
|
const section = create("details", "calendar-local-names");
|
|
|
|
|
|
const countryCount = new Set(details.map((detail) => detail.country_code || detail.country).filter(Boolean)).size || details.length;
|
|
|
|
|
|
const summary = create("summary", "calendar-local-names-summary");
|
|
|
|
|
|
summary.append(
|
|
|
|
|
|
create("span", "calendar-local-summary-name", item.title_english || item.title_original || item.title),
|
|
|
|
|
|
create("span", "calendar-local-summary-count", `${countryCount} 个国家/地区`),
|
|
|
|
|
|
);
|
|
|
|
|
|
section.appendChild(summary);
|
|
|
|
|
|
const list = create("div", "calendar-local-name-list");
|
|
|
|
|
|
details.forEach((detail) => {
|
|
|
|
|
|
const row = create("div", "calendar-local-name-row");
|
|
|
|
|
|
const country = create("strong", "calendar-country-name");
|
|
|
|
|
|
const flag = create("span", "calendar-country-flag", countryFlag(detail.country_code));
|
|
|
|
|
|
flag.setAttribute("aria-hidden", "true");
|
|
|
|
|
|
country.append(flag, create("span", "", `${detail.country || "目标市场"}${detail.country_english ? ` · ${detail.country_english}` : ""}`));
|
|
|
|
|
|
row.append(
|
|
|
|
|
|
country,
|
|
|
|
|
|
create("span", "calendar-local-name", detail.local_name),
|
|
|
|
|
|
create("small", "", `${detail.local_language || compactList(detail.languages || [], 2)} · ${detail.scope || "以官方范围为准"}${(detail.dates || []).length ? ` · ${[...detail.dates].sort().join("、")}` : ""}`),
|
|
|
|
|
|
);
|
|
|
|
|
|
list.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
section.appendChild(list);
|
|
|
|
|
|
return section;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCalendarDateVariants(item) {
|
|
|
|
|
|
const variants = item.date_variants || [];
|
|
|
|
|
|
if (variants.length <= 1) return null;
|
|
|
|
|
|
const section = create("section", "calendar-date-variants");
|
|
|
|
|
|
section.appendChild(create("span", "calendar-section-label", "各地日期差异"));
|
|
|
|
|
|
if (item.date_variation_note) section.appendChild(create("p", "calendar-date-variant-note", item.date_variation_note));
|
|
|
|
|
|
const list = create("div", "calendar-date-variant-list");
|
|
|
|
|
|
variants.forEach((variant) => {
|
|
|
|
|
|
const dateInfo = formatMonthDay(variant.date);
|
|
|
|
|
|
const row = create("div", "calendar-date-variant-row");
|
|
|
|
|
|
row.append(
|
|
|
|
|
|
create("strong", "", `${dateInfo.month}${Number(dateInfo.day)}日`),
|
|
|
|
|
|
create("span", "", variant.label || "地区日期"),
|
|
|
|
|
|
create("small", "", compactList(variant.countries || [], 4)),
|
|
|
|
|
|
);
|
|
|
|
|
|
list.appendChild(row);
|
|
|
|
|
|
});
|
|
|
|
|
|
section.appendChild(list);
|
|
|
|
|
|
return section;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function countryFlag(code) {
|
|
|
|
|
|
const value = String(code || "").toUpperCase();
|
|
|
|
|
|
return /^[A-Z]{2}$/.test(value) ? [...value].map((char) => String.fromCodePoint(127397 + char.charCodeAt(0))).join("") : "🌐";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isCalendarOpportunity(item, asOfDate = state.currentData?.as_of_date) {
|
|
|
|
|
|
const stockingEnd = String(item.stocking_end || item.start_date || "").slice(0, 10);
|
|
|
|
|
|
return ["high", "medium"].includes(item.business_relevance)
|
|
|
|
|
|
&& Boolean(item.stocking_start)
|
|
|
|
|
|
&& (item.product_suggestions || []).length > 0
|
|
|
|
|
|
&& (item.affected_countries || []).length > 0
|
|
|
|
|
|
&& (item.platforms || []).length > 0
|
|
|
|
|
|
&& Boolean(stockingEnd)
|
|
|
|
|
|
&& stockingEnd >= String(asOfDate || "").slice(0, 10);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCalendarCard(item) {
|
|
|
|
|
|
const article = create("article", "calendar-event-card calendar-opportunity-card");
|
|
|
|
|
|
article.id = `calendar-event-${item.id}`;
|
|
|
|
|
|
article.dataset.culture = item.culture_key || "central-europe";
|
|
|
|
|
|
article.dataset.eventType = item.event_type || "public-holiday";
|
|
|
|
|
|
article.dataset.relevance = item.business_relevance || "low";
|
|
|
|
|
|
|
|
|
|
|
|
const dateInfo = formatMonthDay(item.start_date);
|
|
|
|
|
|
const dateBlock = create("div", "calendar-date-block");
|
|
|
|
|
|
dateBlock.append(
|
|
|
|
|
|
create("span", "calendar-date-year", dateInfo.year),
|
|
|
|
|
|
create("span", "calendar-date-month", dateInfo.month),
|
|
|
|
|
|
create("strong", "calendar-date-day", dateInfo.day),
|
|
|
|
|
|
create("span", "calendar-date-weekday", dateInfo.weekday),
|
|
|
|
|
|
create("span", "calendar-culture-label", item.country_count > 1 ? `${item.country_count} 个国家/地区` : (item.region || "全球")),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const content = create("div", "calendar-event-content");
|
|
|
|
|
|
const meta = create("div", "calendar-event-meta");
|
|
|
|
|
|
meta.append(
|
|
|
|
|
|
makeBadge(item.decision_label || "经营观察", `calendar-decision is-${item.business_relevance || "low"}`),
|
|
|
|
|
|
makeBadge(item.planning_stage || "规划期", "calendar-stage"),
|
|
|
|
|
|
create("span", "calendar-event-state.region", item.market_group || item.region || "全球"),
|
|
|
|
|
|
);
|
|
|
|
|
|
const title = create("h3", "calendar-event-title", item.title || "公共假日");
|
|
|
|
|
|
const localNames = makeLocalHolidayNames(item);
|
|
|
|
|
|
|
|
|
|
|
|
const explanation = create("section", "calendar-explanation");
|
|
|
|
|
|
explanation.append(create("span", "calendar-section-label", TEXT.calendar.what), create("p", "", item.what_it_is || item.summary || ""));
|
|
|
|
|
|
|
|
|
|
|
|
const facts = create("div", "calendar-fact-grid");
|
|
|
|
|
|
facts.append(
|
|
|
|
|
|
makeCalendarFact(TEXT.calendar.markets, item.affected_countries || [item.region]),
|
|
|
|
|
|
makeCalendarFact("适用范围", [calendarScopeText(item)]),
|
|
|
|
|
|
makeCalendarFact(TEXT.calendar.languages, item.languages || []),
|
|
|
|
|
|
makeCalendarFact(TEXT.calendar.platforms, item.platforms || []),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const opportunity = create("section", "calendar-business-panel");
|
|
|
|
|
|
opportunity.append(create("span", "calendar-section-label", TEXT.calendar.opportunity));
|
|
|
|
|
|
const scenes = create("p", "calendar-consumption-scenes", compactList(item.consumption_scenes || []));
|
|
|
|
|
|
const products = create("div", "calendar-product-tags");
|
|
|
|
|
|
appendTags(products, item.product_suggestions || []);
|
|
|
|
|
|
opportunity.append(scenes, products);
|
|
|
|
|
|
|
|
|
|
|
|
const platformPanel = create("section", "calendar-platform-panel");
|
|
|
|
|
|
const platformRows = item.platform_actions || [];
|
|
|
|
|
|
if (platformRows.length) {
|
|
|
|
|
|
platformPanel.appendChild(create("span", "calendar-section-label", TEXT.calendar.platformActions));
|
|
|
|
|
|
platformRows.forEach((row) => {
|
|
|
|
|
|
const action = create("div", "calendar-platform-action");
|
|
|
|
|
|
action.append(makePlatformIdentity(row.platform), create("p", "", row.action));
|
|
|
|
|
|
platformPanel.appendChild(action);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const guidance = create("div", "calendar-guidance-grid");
|
|
|
|
|
|
if ((item.design_opportunities || []).length) {
|
|
|
|
|
|
const design = create("section", "calendar-guidance is-design");
|
|
|
|
|
|
design.append(create("span", "calendar-section-label", TEXT.calendar.design), create("p", "", compactList(item.design_opportunities, 3)));
|
|
|
|
|
|
guidance.appendChild(design);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const actionBox = create("section", "calendar-action-box");
|
|
|
|
|
|
const windowTitle = create("div", "calendar-stock-window-title");
|
|
|
|
|
|
windowTitle.append(create("span", "", "建议备货窗口"), create("strong", "", `${item.stocking_start || ""} 至 ${item.stocking_end || ""}`));
|
|
|
|
|
|
const nextAction = create("div", "calendar-next-action");
|
|
|
|
|
|
nextAction.append(create("span", "calendar-section-label", TEXT.calendar.next), create("p", "", item.next_action || item.opportunity || ""));
|
|
|
|
|
|
actionBox.append(windowTitle, nextAction);
|
|
|
|
|
|
|
|
|
|
|
|
const footer = create("div", "calendar-event-footer");
|
|
|
|
|
|
const source = create("div", "calendar-event-source");
|
|
|
|
|
|
source.append(
|
|
|
|
|
|
create("strong", "", `${TEXT.calendar.facts}:${item.source || "权威日期来源"}`),
|
|
|
|
|
|
create("span", "", `${TEXT.calendar.analysis} · ${item.analysis_basis || "日期事实与经营建议分层展示"}`),
|
|
|
|
|
|
);
|
|
|
|
|
|
const actions = create("div", "calendar-event-actions");
|
|
|
|
|
|
actions.append(makeCalendarDownloadButton(item), makeShareButton(item), makeExternalLink(sourceLinkLabel(item), item.url || item.source_url));
|
|
|
|
|
|
appendTranslationLinks(actions, item);
|
|
|
|
|
|
const cultureUrl = cultureReferenceUrl(item);
|
|
|
|
|
|
if (cultureUrl) actions.appendChild(makeExternalLink(TEXT.calendar.cultureReference, cultureUrl));
|
|
|
|
|
|
if (item.visual_reference_url) actions.appendChild(makeExternalLink(TEXT.calendar.visualReference, item.visual_reference_url));
|
|
|
|
|
|
footer.append(source, actions);
|
|
|
|
|
|
|
|
|
|
|
|
content.append(meta, title);
|
|
|
|
|
|
if (localNames) content.appendChild(localNames);
|
|
|
|
|
|
const dateVariants = makeCalendarDateVariants(item);
|
|
|
|
|
|
if (dateVariants) content.appendChild(dateVariants);
|
|
|
|
|
|
content.append(explanation, facts, opportunity);
|
|
|
|
|
|
if (platformPanel.childElementCount) content.appendChild(platformPanel);
|
|
|
|
|
|
if (guidance.childElementCount) content.appendChild(guidance);
|
|
|
|
|
|
const hasLiveAction = isCalendarOpportunity(item);
|
|
|
|
|
|
if (hasLiveAction) content.appendChild(actionBox);
|
|
|
|
|
|
content.appendChild(footer);
|
|
|
|
|
|
article.append(dateBlock, content);
|
|
|
|
|
|
return article;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function makeCalendarSelect(label, value, options, onChange) {
|
|
|
|
|
|
const wrapper = create("label", "calendar-filter-field");
|
|
|
|
|
|
wrapper.appendChild(create("span", "", label));
|
|
|
|
|
|
const select = create("select", "calendar-filter-select");
|
|
|
|
|
|
options.forEach(([optionValue, optionLabel]) => {
|
|
|
|
|
|
const option = create("option", "", optionLabel);
|
|
|
|
|
|
option.value = optionValue;
|
|
|
|
|
|
select.appendChild(option);
|
|
|
|
|
|
});
|
|
|
|
|
|
select.value = value;
|
|
|
|
|
|
select.addEventListener("change", (event) => onChange(event.target.value));
|
|
|
|
|
|
wrapper.appendChild(select);
|
|
|
|
|
|
return wrapper;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCalendarMonthGrid(year, month, items) {
|
|
|
|
|
|
const section = create("section", "calendar-month-grid-wrap");
|
|
|
|
|
|
const heading = create("div", "calendar-month-grid-heading");
|
|
|
|
|
|
heading.append(
|
|
|
|
|
|
create("h3", "", `${year}年${month}月`),
|
|
|
|
|
|
create("span", "calendar-month-count", `本月 ${items.length} 条${state.calendarFocus === "opportunity" ? "备货机会" : "运营节点"}`),
|
|
|
|
|
|
);
|
|
|
|
|
|
const grid = create("div", "calendar-month-grid");
|
|
|
|
|
|
["一", "二", "三", "四", "五", "六", "日"].forEach((label) => grid.appendChild(create("span", "calendar-weekday", label)));
|
|
|
|
|
|
const firstWeekday = (new Date(year, month - 1, 1).getDay() + 6) % 7;
|
|
|
|
|
|
const days = new Date(year, month, 0).getDate();
|
|
|
|
|
|
for (let index = 0; index < firstWeekday; index += 1) grid.appendChild(create("span", "calendar-day is-empty"));
|
|
|
|
|
|
for (let day = 1; day <= days; day += 1) {
|
|
|
|
|
|
const cell = create("div", "calendar-day");
|
|
|
|
|
|
cell.appendChild(create("span", "calendar-day-number", String(day)));
|
|
|
|
|
|
const date = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
|
|
|
|
items.flatMap((item) => {
|
|
|
|
|
|
const variants = (item.date_variants || []).filter((variant) => variant.date === date);
|
|
|
|
|
|
if (variants.length) return variants.map((variant) => ({ item, variant }));
|
|
|
|
|
|
return String(item.start_date).slice(0, 10) === date ? [{ item, variant: null }] : [];
|
|
|
|
|
|
}).forEach(({ item, variant }) => {
|
|
|
|
|
|
const label = variant?.label === "平安夜" ? "平安夜" : item.title;
|
|
|
|
|
|
const event = create("a", `calendar-day-event is-${item.business_relevance || "low"}`, label);
|
|
|
|
|
|
event.href = `#calendar-event-${item.id}`;
|
|
|
|
|
|
event.title = `${item.title} · ${compactList(item.affected_countries || [], 2)}`;
|
|
|
|
|
|
cell.appendChild(event);
|
|
|
|
|
|
});
|
|
|
|
|
|
grid.appendChild(cell);
|
|
|
|
|
|
}
|
|
|
|
|
|
section.append(heading, grid);
|
|
|
|
|
|
return section;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderCalendar(data, container) {
|
|
|
|
|
|
const sourceItems = Array.isArray(data.calendar_insights) ? data.calendar_insights : (data.calendar || []);
|
|
|
|
|
|
const all = sourceItems.filter(matchesFilters);
|
|
|
|
|
|
const defaultYear = Number(String(data.as_of_date || "").slice(0, 4));
|
|
|
|
|
|
const years = [...new Set(all.map((item) => Number(String(item.start_date || "").slice(0, 4))).filter(Boolean))].sort();
|
|
|
|
|
|
if (!state.calendarYear || !years.includes(state.calendarYear)) {
|
|
|
|
|
|
state.calendarYear = years.includes(defaultYear) ? defaultYear : years[0];
|
|
|
|
|
|
state.calendarMonth = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const planner = create("section", "calendar-planner");
|
|
|
|
|
|
const plannerCopy = create("div", "calendar-planner-copy");
|
|
|
|
|
|
plannerCopy.append(
|
|
|
|
|
|
create("p", "calendar-planner-eyebrow", `${data.summary?.calendar_featured || 0} 个重点商机 · ${data.summary?.calendar_events || 0} 个官方日期节点`),
|
|
|
|
|
|
create("h3", "", `${state.calendarYear} 市场备货决策日历`),
|
|
|
|
|
|
create("p", "", "默认只看与销售、设计、入仓或运营有关的节点;同一节日跨国聚合,并明确区分官方事实与系统经营分析。"),
|
|
|
|
|
|
);
|
|
|
|
|
|
const yearSwitch = create("div", "calendar-year-switch");
|
|
|
|
|
|
years.forEach((year) => {
|
|
|
|
|
|
const button = create("button", `calendar-year-btn${state.calendarYear === year ? " active" : ""}`, String(year));
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
state.calendarYear = year;
|
|
|
|
|
|
state.calendarMonth = 0;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
});
|
|
|
|
|
|
yearSwitch.appendChild(button);
|
|
|
|
|
|
});
|
|
|
|
|
|
planner.appendChild(plannerCopy);
|
|
|
|
|
|
container.appendChild(planner);
|
|
|
|
|
|
|
|
|
|
|
|
const marketGroups = [...new Set(all.flatMap((item) => item.affected_regions || []))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
|
|
|
|
|
const marketMatched = all.filter((item) => !state.calendarMarketGroup || (item.affected_regions || []).includes(state.calendarMarketGroup));
|
|
|
|
|
|
const countries = [...new Set(marketMatched.flatMap((item) => item.country_details || [])
|
|
|
|
|
|
.map((detail) => detail.country)
|
|
|
|
|
|
.filter((value) => value && !/市场|全球/.test(value)))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
|
|
|
|
|
const countryMatched = marketMatched.filter((item) => !state.calendarCountry || (item.affected_countries || []).includes(state.calendarCountry));
|
|
|
|
|
|
const productCategories = [...new Set(countryMatched.flatMap(calendarProductCategories))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
|
|
|
|
|
const platforms = [...new Set(countryMatched.flatMap(calendarPlatformFamilies))].sort((a, b) => a.localeCompare(b, "zh-CN"));
|
|
|
|
|
|
if (state.calendarCountry && !countries.includes(state.calendarCountry)) state.calendarCountry = "";
|
|
|
|
|
|
if (state.calendarProduct && !productCategories.includes(state.calendarProduct)) state.calendarProduct = "";
|
|
|
|
|
|
if (state.calendarPlatform && !platforms.includes(state.calendarPlatform)) state.calendarPlatform = "";
|
|
|
|
|
|
|
|
|
|
|
|
const decisionToolbar = create("section", "calendar-decision-toolbar");
|
|
|
|
|
|
const filterRow = create("div", "calendar-filter-row");
|
|
|
|
|
|
const modeRow = create("div", "calendar-mode-row");
|
|
|
|
|
|
const scopeSwitch = create("div", "calendar-scope-switch");
|
|
|
|
|
|
[["opportunity", TEXT.calendar.important], ["all", TEXT.calendar.all]].forEach(([key, label]) => {
|
|
|
|
|
|
const button = create("button", `calendar-scope-btn${state.calendarFocus === key ? " active" : ""}`, label);
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
state.calendarFocus = key;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
});
|
|
|
|
|
|
scopeSwitch.appendChild(button);
|
|
|
|
|
|
});
|
|
|
|
|
|
filterRow.append(
|
|
|
|
|
|
makeCalendarSelect("市场区域", state.calendarMarketGroup, [["", TEXT.calendar.allMarkets], ...marketGroups.map((value) => [value, value])], (value) => {
|
|
|
|
|
|
state.calendarMarketGroup = value;
|
|
|
|
|
|
state.calendarCountry = "";
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
}),
|
|
|
|
|
|
makeCalendarSelect("国家/地区", state.calendarCountry, [["", TEXT.calendar.allCountries], ...countries.map((value) => [value, value])], (value) => {
|
|
|
|
|
|
state.calendarCountry = value;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
}),
|
|
|
|
|
|
makeCalendarSelect("产品方向", state.calendarProduct, [["", TEXT.calendar.allProducts], ...productCategories.map((value) => [value, value])], (value) => {
|
|
|
|
|
|
state.calendarProduct = value;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
}),
|
|
|
|
|
|
makeCalendarSelect("销售平台(经营动作)", state.calendarPlatform, [["", TEXT.calendar.allPlatforms], ...platforms.map((value) => [value, value])], (value) => {
|
|
|
|
|
|
state.calendarPlatform = value;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
modeRow.append(scopeSwitch, yearSwitch);
|
|
|
|
|
|
decisionToolbar.append(filterRow, modeRow);
|
|
|
|
|
|
decisionToolbar.appendChild(create("p", "calendar-platform-filter-hint", TEXT.calendar.platformFilterHint));
|
|
|
|
|
|
container.appendChild(decisionToolbar);
|
|
|
|
|
|
|
2026-08-15 19:17:16 +08:00
|
|
|
|
const today = calendarTodayIso();
|
2026-08-14 19:26:39 +08:00
|
|
|
|
const selectedMarketItems = all.filter((item) => {
|
|
|
|
|
|
if (state.calendarMarketGroup && !(item.affected_regions || []).includes(state.calendarMarketGroup)) return false;
|
|
|
|
|
|
if (state.calendarCountry && !(item.affected_countries || []).includes(state.calendarCountry)) return false;
|
|
|
|
|
|
if (state.calendarProduct && !calendarProductCategories(item).includes(state.calendarProduct)) return false;
|
|
|
|
|
|
if (state.calendarPlatform && !calendarPlatformFamilies(item).includes(state.calendarPlatform)) return false;
|
2026-08-15 19:17:16 +08:00
|
|
|
|
if (state.calendarFocus === "opportunity" && !isCalendarOpportunity(item, today)) return false;
|
2026-08-14 19:26:39 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
});
|
|
|
|
|
|
const yearItems = selectedMarketItems.filter((item) => Number(String(item.start_date || "").slice(0, 4)) === state.calendarYear);
|
2026-08-15 19:17:16 +08:00
|
|
|
|
const displayYearItems = state.calendarMonth ? yearItems : calendarDefaultItems(yearItems, state.calendarYear);
|
2026-08-14 19:26:39 +08:00
|
|
|
|
const monthCounts = new Map();
|
2026-08-15 19:17:16 +08:00
|
|
|
|
displayYearItems.forEach((item) => {
|
2026-08-14 19:26:39 +08:00
|
|
|
|
const month = Number(String(item.start_date || "").slice(5, 7));
|
|
|
|
|
|
monthCounts.set(month, (monthCounts.get(month) || 0) + 1);
|
|
|
|
|
|
});
|
|
|
|
|
|
const monthRail = create("div", "calendar-month-rail");
|
|
|
|
|
|
const allButton = create("button", `calendar-month-btn${state.calendarMonth === 0 ? " active" : ""}`);
|
2026-08-15 19:17:16 +08:00
|
|
|
|
allButton.append(create("strong", "", "全年"), create("span", "calendar-month-count", `${displayYearItems.length} 条`));
|
2026-08-14 19:26:39 +08:00
|
|
|
|
allButton.type = "button";
|
|
|
|
|
|
allButton.addEventListener("click", () => {
|
|
|
|
|
|
state.calendarMonth = 0;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
});
|
|
|
|
|
|
monthRail.appendChild(allButton);
|
|
|
|
|
|
for (let month = 1; month <= 12; month += 1) {
|
|
|
|
|
|
if (!monthCounts.has(month)) continue;
|
|
|
|
|
|
const button = create("button", `calendar-month-btn${state.calendarMonth === month ? " active" : ""}`);
|
|
|
|
|
|
button.append(create("strong", "", `${month}月`), create("span", "calendar-month-count", `${monthCounts.get(month)} 条`));
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
state.calendarMonth = month;
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
});
|
|
|
|
|
|
monthRail.appendChild(button);
|
|
|
|
|
|
}
|
|
|
|
|
|
container.appendChild(monthRail);
|
|
|
|
|
|
|
|
|
|
|
|
const highlightPriority = [
|
|
|
|
|
|
"back-to-school", "singles-day", "black-friday", "cyber-monday", "winter-holiday",
|
|
|
|
|
|
"christmas", "lunar-new-year", "eid-fitr", "super-bowl", "la28",
|
|
|
|
|
|
];
|
2026-08-15 19:17:16 +08:00
|
|
|
|
const highValueItems = displayYearItems
|
|
|
|
|
|
.filter((item) => item.business_relevance === "high" && isCalendarOpportunity(item, today));
|
|
|
|
|
|
const selected = sortCalendarItems(displayYearItems
|
2026-08-14 19:26:39 +08:00
|
|
|
|
.filter((item) => !state.calendarMonth || Number(String(item.start_date || "").slice(5, 7)) === state.calendarMonth)
|
2026-08-15 19:17:16 +08:00
|
|
|
|
, new Date());
|
2026-08-14 19:26:39 +08:00
|
|
|
|
if (state.calendarMonth) container.appendChild(renderCalendarMonthGrid(state.calendarYear, state.calendarMonth, selected));
|
|
|
|
|
|
const highlightPool = state.calendarMonth
|
|
|
|
|
|
? highValueItems.filter((item) => Number(String(item.start_date).slice(5, 7)) === state.calendarMonth)
|
|
|
|
|
|
: highValueItems;
|
|
|
|
|
|
const highlights = [
|
|
|
|
|
|
...highlightPriority.map((key) => highlightPool.find((item) => item.festival_key === key)).filter(Boolean),
|
|
|
|
|
|
...highlightPool,
|
|
|
|
|
|
].filter((item, index, rows) => rows.findIndex((row) => row.id === item.id) === index);
|
|
|
|
|
|
if (highlights.length) {
|
|
|
|
|
|
const highlightRail = create("section", "calendar-highlight-rail");
|
|
|
|
|
|
highlightRail.appendChild(create("strong", "", state.calendarMonth ? `${state.calendarMonth}月重点消费与备货节点` : "全年关键消费与备货节点"));
|
|
|
|
|
|
const links = create("div", "calendar-highlight-links");
|
|
|
|
|
|
highlights.forEach((item) => {
|
|
|
|
|
|
const button = create("button", "calendar-highlight-button");
|
|
|
|
|
|
button.type = "button";
|
|
|
|
|
|
button.append(create("span", "", String(item.start_date).slice(5).replace("-", "/")), create("b", "", item.title));
|
|
|
|
|
|
button.addEventListener("click", () => {
|
|
|
|
|
|
state.calendarMonth = Number(String(item.start_date).slice(5, 7));
|
|
|
|
|
|
state.renderList?.(data, container);
|
|
|
|
|
|
});
|
|
|
|
|
|
links.appendChild(button);
|
|
|
|
|
|
});
|
|
|
|
|
|
highlightRail.appendChild(links);
|
|
|
|
|
|
container.appendChild(highlightRail);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!selected.length) {
|
|
|
|
|
|
container.appendChild(create("div", "intelligence-empty", "当前市场和月份没有重点备货商机,可切换为“全部节点”查看运营假期。"));
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const visible = selected;
|
|
|
|
|
|
const groups = new Map();
|
|
|
|
|
|
visible.forEach((item) => {
|
|
|
|
|
|
const month = Number(String(item.start_date || "").slice(5, 7));
|
|
|
|
|
|
if (!groups.has(month)) groups.set(month, []);
|
|
|
|
|
|
groups.get(month).push(item);
|
|
|
|
|
|
});
|
|
|
|
|
|
groups.forEach((items, month) => {
|
|
|
|
|
|
const section = create("section", "calendar-month-section");
|
|
|
|
|
|
const heading = create("div", "calendar-month-heading");
|
|
|
|
|
|
heading.append(create("h3", "", `${month}月`), create("span", "", `${items.length} 个${state.calendarFocus === "opportunity" ? "精准机会" : "节点"}`));
|
|
|
|
|
|
const grid = create("div", "calendar-event-grid is-insights");
|
|
|
|
|
|
items.forEach((item) => grid.appendChild(makeCalendarCard(item)));
|
|
|
|
|
|
section.append(heading, grid);
|
|
|
|
|
|
container.appendChild(section);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 19:17:16 +08:00
|
|
|
|
export { TEXT, create, openLightbox, sectionItems, trendItems, normalized, matchesFilters, formatDate, formatVerified, sectionOf, makeVisual, makeBadge, makeExternalLink, sourceLinkLabel, currentTranslationProvider, preferredTranslation, detectViewerCountry, cultureReferenceUrl, appendTranslationLinks, appendPrefixedTranslationLink, makePlatformActionGroup, itemShareUrl, makeShareButton, makeCard, appendTags, formatMonthDay, compactList, calendarProductCategories, escapeIcs, nextDateValue, makeCalendarDownloadButton, calendarScopeText, makeCalendarFact, makeLocalHolidayNames, makeCalendarDateVariants, countryFlag, isCalendarOpportunity, makeCalendarCard, makeCalendarSelect, renderCalendarMonthGrid, renderCalendar };
|