Compare commits

...
2 Commits
Author SHA1 Message Date
Jack 7af253f081 fix: 修复独立站日历节日去重与中文资料链接 2026-08-17 12:58:19 +08:00
Jack b1b7c13c97 fix: 按UTC日期优化独立站日历筛选
2026-08-15:日历默认视图按当前 UTC 日期过滤与排序,未来和当天节点优先,已过节点降权;显式月份仍保留完整历史。日历页隐藏重复目标区域筛选,平台渲染模块独立拆分,更新静态资源版本号与测试契约,避免 Gitea/IIS/Nginx 部署命中旧缓存。
2026-08-15 19:17:16 +08:00
15 changed files with 15581 additions and 20951 deletions
+29
View File
@@ -1,5 +1,34 @@
# 更新日志
## 2026-08-17
### 日历数据规则与旧快照修复
- 按当前 UTC 日期重建公开日历快照,过滤已结束事件并保留未来事件的可用排序。
- 将元旦/新年假期、开斋节、宰牲节和农历新年按年度合并,卡内保留不同国家的日期差异。
- 修正日本 `Coming of Age Day`、`Greenery Day`、印度尼西亚独立日和菲律宾国家英雄日的中文标题;阻止“国家纪念与公共假日”等泛称跨国误合并。
- 中国市场节日不再生成无意义的机器翻译入口;百度百科元旦链接固定指向“元旦”;Bing 图片查询收敛为标准节日名、国家和 `holiday`。
- 同步更新 Nager 日历翻译字段、公开 JSON 快照和回归测试,代码与数据应作为同一提交回滚。
### 验证与恢复
- Python 合同测试 11 项、Node 前端测试 14 项通过。
- `docs/CODEX_TASK_STATE.md` 固化任务目标、验收标准和回滚方式,避免服务中断后丢失上下文。
## 2026-08-15
### 日历实用性与筛选交互
- 日历排序统一使用当前 UTC 日期:未来与当天项目按日期升序展示,已过项目降权到当月未来项目之后,并在已过项目之间按日期倒序排列。
- 当前年份的默认日历视图不再把今天之前的节日放入首屏月份统计;用户主动选择某个月时仍可查看该月完整历史记录。
- “目标区域”在日历页直接隐藏,避免与下方市场区域、国家/地区、产品方向和销售平台筛选重复;离开日历页后自动恢复。
- 删除主站校验时间行里的旧式箭头分享控件对应逻辑;独立站保留正常的顶部分享按钮。同步更新独立站静态资源版本号,避免部署命中旧缓存。
### 验证
- 独立站 Node 前端测试 14 项、Python 合同测试 11 项通过。
- 独立站静态构建成功;390×844 移动端检查通过,无控制台错误,日历首条为 2026-08-15。
本文件按日期倒序记录独立版的代码、数据、交互和部署变化。每次提交必须先更新本文件,确保公司研发人员可以判断影响范围并使用 `git revert <commit>` 回滚。
## 2026-08-14 - 平台渠道入口与数据快照对齐
+2 -2
View File
@@ -6,12 +6,12 @@
## 当前基线
当前提交数据以 `data/*.json` 为准,业务基准日为 **2026-08-14**:
当前提交数据以 `data/*.json` 为准,业务基准日为 **2026-08-17**:
| 指标 | 当前值 |
|---|---:|
| 严格权威源 | 59/59 |
| 日历原始事件 / 聚合机会 | 1270 / 673 |
| 日历原始事件 / 聚合机会 | 1261 / 640 |
| 今日必看 | 24 |
| 展会 | 79 |
| 政策与召回 | 48 |
+15273 -20809
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
# 任务状态:日历数据规则与旧快照修复
更新时间:2026-08-17(Asia/Shanghai)
## 目标
- 日历按当前 UTC 日期过滤和排序,已过日期降权,不让过期节日占据默认首屏。
- 合并同一节日的跨天、跨国家记录;保留各国日期差异,不把连续假期拆成重复卡片。
- 清理国家纪念类泛称、错误中文译名和失效百度百科链接。
- 中国市场记录不生成无意义的机器翻译入口;图片查询只使用节日标准名、国家和 `holiday` 短查询词。
- 独立站与主站分别维护、分别测试、分别提交和推送。
## 验收标准
- 独立站 Python 11 项、Node 前端 14 项测试通过。
- 2027 年元旦/新年假期合并为一张卡,至少覆盖 1 月 1 日至 1 月 8 日,并包含中国与俄罗斯语境。
- 2027 年宰牲节合并为一张连续假期卡;日本 `Coming of Age Day`、`Greenery Day` 显示中文规范名。
- 不输出带英文副标题的“国家纪念与公共假日”等泛称卡片。
## 回滚
本次代码、测试和公开 JSON 必须作为同一提交回滚:`git revert <commit>`,随后重新构建静态目录。不要把父目录工作树的改动提交到本仓库。
+1 -17
View File
@@ -67,21 +67,6 @@
.intelligence-overview-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
#intelligenceUpdatedAt { color: var(--muted); font-size: 12px; }
.intelligence-share-btn {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 6px;
background: transparent;
color: var(--muted);
font-weight: 800;
cursor: pointer;
}
.intelligence-share-btn:hover { border-color: var(--intel-brand-blue); color: var(--intel-brand-blue); }
.intelligence-stats {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
@@ -608,9 +593,8 @@
.intelligence-overview p:last-child { color: var(--ink-secondary); font-size: 16px; line-height: 1.65; }
.intelligence-overview-actions { align-items: center; }
.intelligence-overview-actions > span { color: var(--ink-secondary); font-size: 13px; }
.intelligence-share-btn { display: inline-grid; width: 44px; min-width: 44px; height: 44px; padding: 0; place-items: center; border: 1px solid var(--line); background: transparent; color: var(--muted); font-size: 20px; }
.intelligence-share-btn:hover, .intelligence-share-btn:focus-visible { border-color: var(--ink-secondary); background: var(--trans-bg); color: var(--ink); }
.intelligence-search-field { display: grid; gap: 6px; min-width: 0; color: var(--ink-secondary); font-size: 14px; font-weight: 700; }
.intelligence-search-field[hidden] { display: none !important; }
.intelligence-toolbar input, .intelligence-toolbar select { min-height: 48px; font-size: 16px; }
.intelligence-tab { min-height: 48px; font-size: 16px; }
.intelligence-tab-count, .intelligence-badge { font-size: 13px; }
+1 -1
View File
@@ -1,4 +1,4 @@
import { getIntelligenceSection, renderIntelligence, setIntelligenceSection, shareIntelligence } from "./render-intelligence.js?v=20260814o";
import { getIntelligenceSection, renderIntelligence, setIntelligenceSection, shareIntelligence } from "./render-intelligence.js?v=20260815a";
const DATA_ROOT = "./data";
+32 -96
View File
@@ -543,6 +543,28 @@ function formatMonthDay(value) {
};
}
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);
}
function compactList(values, limit = 5) {
const unique = [...new Set((values || []).filter(Boolean))];
if (unique.length <= limit) return unique.join("、") || "暂无专项信息";
@@ -902,23 +924,25 @@ function renderCalendar(data, container) {
decisionToolbar.appendChild(create("p", "calendar-platform-filter-hint", TEXT.calendar.platformFilterHint));
container.appendChild(decisionToolbar);
const today = calendarTodayIso();
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;
if (state.calendarFocus === "opportunity" && !isCalendarOpportunity(item, data.as_of_date)) return false;
if (state.calendarFocus === "opportunity" && !isCalendarOpportunity(item, today)) return false;
return true;
});
const yearItems = selectedMarketItems.filter((item) => Number(String(item.start_date || "").slice(0, 4)) === state.calendarYear);
const displayYearItems = state.calendarMonth ? yearItems : calendarDefaultItems(yearItems, state.calendarYear);
const monthCounts = new Map();
yearItems.forEach((item) => {
displayYearItems.forEach((item) => {
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" : ""}`);
allButton.append(create("strong", "", "全年"), create("span", "calendar-month-count", `${yearItems.length} 条`));
allButton.append(create("strong", "", "全年"), create("span", "calendar-month-count", `${displayYearItems.length} 条`));
allButton.type = "button";
allButton.addEventListener("click", () => {
state.calendarMonth = 0;
@@ -942,12 +966,11 @@ function renderCalendar(data, container) {
"back-to-school", "singles-day", "black-friday", "cyber-monday", "winter-holiday",
"christmas", "lunar-new-year", "eid-fitr", "super-bowl", "la28",
];
const highValueItems = yearItems
.filter((item) => item.business_relevance === "high" && isCalendarOpportunity(item, data.as_of_date))
.sort((a, b) => String(a.start_date).localeCompare(String(b.start_date)));
const selected = yearItems
const highValueItems = displayYearItems
.filter((item) => item.business_relevance === "high" && isCalendarOpportunity(item, today));
const selected = sortCalendarItems(displayYearItems
.filter((item) => !state.calendarMonth || Number(String(item.start_date || "").slice(5, 7)) === state.calendarMonth)
.sort((a, b) => String(a.start_date).localeCompare(String(b.start_date)) || Number(b.priority_score || 0) - Number(a.priority_score || 0));
, new Date());
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)
@@ -996,91 +1019,4 @@ function renderCalendar(data, container) {
});
}
function makePlatformCard(item) {
const card = create("article", "platform-card");
const header = create("div", "platform-card-header");
const logoBox = create("div", "platform-logo-box");
const logo = create("img", "platform-logo");
logo.src = `./assets/platform-logos/${item.logo}.svg`;
logo.alt = `${item.name} 标志`;
logo.width = 112;
logo.height = 48;
logoBox.appendChild(logo);
const title = create("div", "platform-title");
title.append(create("span", "", `${item.region} · ${item.type}`), create("h3", "", item.name));
header.append(logoBox, title, makeBadge(`${item.access_level} ${item.access_label}`, `platform-access is-${String(item.access_level).toLowerCase()}`));
const markets = create("div", "platform-markets");
appendTags(markets, item.markets || []);
const fit = create("div", "platform-fit");
fit.append(create("strong", "", "适合谁"), create("p", "", compactList(item.fit_sellers || [])));
const category = create("div", "platform-category");
category.append(create("strong", "", "家纺经营重点"), create("p", "", item.textile_category || "打开平台类目核对。"));
const access = create("div", "platform-access-note");
access.append(create("strong", "", "入驻判断"), create("p", "", item.cross_border || "以平台当前规则为准。"));
const actions = create("div", "platform-actions");
if (item.homepage_url && item.homepage_url !== item.seller_url) actions.appendChild(makePlatformActionGroup("打开平台官网", item.homepage_url, item, "homepage"));
actions.appendChild(makePlatformActionGroup("去官方入驻", item.seller_url, item, "seller", "platform-action primary"));
if (item.language_url) actions.appendChild(makeExternalLink("官网学习中心(可选中文)", item.language_url, "platform-action language"));
const proof = create("p", "platform-proof", `${item.confidence} · ${item.link_status || "待人工复核"} · 核验 ${String(item.checked_at || "").slice(0, 10)}`);
card.append(header, markets, category, access, fit, actions, proof);
return card;
}
function renderPlatforms(data, container) {
const all = (data.platforms || []).filter(matchesFilters);
const toolbar = create("section", "platform-filter-toolbar");
const makeField = (label, control, extraClass = "") => {
const field = create("label", `platform-filter-field ${extraClass}`.trim());
field.append(create("span", "", label), control);
return field;
};
const makeSelect = (placeholder, values, current) => {
const select = create("select", "platform-filter-control");
select.appendChild(Object.assign(create("option", "", placeholder), { value: "" }));
values.forEach((value) => select.appendChild(Object.assign(create("option", "", value), { value })));
select.value = current;
return select;
};
const markets = [...new Set(all.flatMap((item) => item.markets || []))].sort((a, b) => a.localeCompare(b, "zh-CN"));
const types = [...new Set(all.map((item) => item.type).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN"));
const marketSelect = makeSelect("全部市场", markets, state.platformMarket);
const typeSelect = makeSelect("全部类型", types, state.platformType);
const accessSelect = makeSelect("全部开放等级", ["A", "B", "C"].filter((level) => all.some((item) => item.access_level === level)), state.platformAccess);
const search = create("input", "platform-filter-control");
search.type = "search";
search.name = "platform-channel-search";
search.autocomplete = "off";
search.placeholder = "搜索平台名称、国家或家纺类目…";
search.value = state.platformSearch;
toolbar.append(
makeField("目标市场", marketSelect),
makeField("渠道类型", typeSelect),
makeField("开放等级", accessSelect),
makeField("平台检索", search, "platform-filter-search"),
);
const header = create("section", "scene-summary platform-summary");
const grid = create("div", "platform-grid");
const apply = () => {
const keyword = normalized(state.platformSearch);
const selected = all.filter((item) => {
if (state.platformMarket && !(item.markets || []).includes(state.platformMarket)) return false;
if (state.platformType && item.type !== state.platformType) return false;
if (state.platformAccess && item.access_level !== state.platformAccess) return false;
if (!keyword) return true;
return normalized([item.name, item.region, item.type, item.textile_category, item.cross_border, ...(item.markets || []), ...(item.fit_sellers || [])].join(" ")).includes(keyword);
}).sort((a, b) => (Number(b.home_textile_sales_score || 0) + Number(b.emergence_score || 0)) - (Number(a.home_textile_sales_score || 0) + Number(a.emergence_score || 0)) || String(a.access_level).localeCompare(String(b.access_level)) || a.name.localeCompare(b.name, "zh-CN"));
header.innerHTML = "";
header.append(create("strong", "", `${selected.length}/${all.length} 个家纺销售渠道`), create("span", "", "准入条件以平台卖家中心为准"));
grid.innerHTML = "";
selected.forEach((item) => grid.appendChild(makePlatformCard(item)));
if (!selected.length) grid.appendChild(create("div", "intelligence-empty", "当前筛选条件下没有已核验平台,请调整市场或开放等级。"));
};
marketSelect.addEventListener("change", () => { state.platformMarket = marketSelect.value; apply(); });
typeSelect.addEventListener("change", () => { state.platformType = typeSelect.value; apply(); });
accessSelect.addEventListener("change", () => { state.platformAccess = accessSelect.value; apply(); });
search.addEventListener("input", () => { state.platformSearch = search.value; apply(); });
container.append(toolbar, header, grid);
apply();
}
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, makePlatformCard, renderPlatforms };
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 };
@@ -0,0 +1,84 @@
import { state } from "./render-intelligence-state.js?v=20260814e";
import { create, normalized, matchesFilters, makeBadge, makeExternalLink, makePlatformActionGroup, appendTags, compactList } from "./render-intelligence-core.js?v=20260815a";
function makePlatformCard(item) {
const card = create("article", "platform-card");
const header = create("div", "platform-card-header");
const logoBox = create("div", "platform-logo-box");
const logo = create("img", "platform-logo");
logo.src = `./assets/platform-logos/${item.logo}.svg`;
logo.alt = `${item.name} 标志`;
logo.width = 112;
logo.height = 48;
logoBox.appendChild(logo);
const title = create("div", "platform-title");
title.append(create("span", "", `${item.region} · ${item.type}`), create("h3", "", item.name));
header.append(logoBox, title, makeBadge(`${item.access_level} ${item.access_label}`, `platform-access is-${String(item.access_level).toLowerCase()}`));
const markets = create("div", "platform-markets");
appendTags(markets, item.markets || []);
const fit = create("div", "platform-fit");
fit.append(create("strong", "", "适合谁"), create("p", "", compactList(item.fit_sellers || [])));
const category = create("div", "platform-category");
category.append(create("strong", "", "家纺经营重点"), create("p", "", item.textile_category || "打开平台类目核对。"));
const access = create("div", "platform-access-note");
access.append(create("strong", "", "入驻判断"), create("p", "", item.cross_border || "以平台当前规则为准。"));
const actions = create("div", "platform-actions");
if (item.homepage_url && item.homepage_url !== item.seller_url) actions.appendChild(makePlatformActionGroup("打开平台官网", item.homepage_url, item, "homepage"));
actions.appendChild(makePlatformActionGroup("去官方入驻", item.seller_url, item, "seller", "platform-action primary"));
if (item.language_url) actions.appendChild(makeExternalLink("官网学习中心(可选中文)", item.language_url, "platform-action language"));
const proof = create("p", "platform-proof", `${item.confidence} · ${item.link_status || "待人工复核"} · 核验 ${String(item.checked_at || "").slice(0, 10)}`);
card.append(header, markets, category, access, fit, actions, proof);
return card;
}
export function renderPlatforms(data, container) {
const all = (data.platforms || []).filter(matchesFilters);
const toolbar = create("section", "platform-filter-toolbar");
const makeField = (label, control, extraClass = "") => {
const field = create("label", `platform-filter-field ${extraClass}`.trim());
field.append(create("span", "", label), control);
return field;
};
const makeSelect = (placeholder, values, current) => {
const select = create("select", "platform-filter-control");
select.appendChild(Object.assign(create("option", "", placeholder), { value: "" }));
values.forEach((value) => select.appendChild(Object.assign(create("option", "", value), { value })));
select.value = current;
return select;
};
const markets = [...new Set(all.flatMap((item) => item.markets || []))].sort((a, b) => a.localeCompare(b, "zh-CN"));
const types = [...new Set(all.map((item) => item.type).filter(Boolean))].sort((a, b) => a.localeCompare(b, "zh-CN"));
const marketSelect = makeSelect("全部市场", markets, state.platformMarket);
const typeSelect = makeSelect("全部类型", types, state.platformType);
const accessSelect = makeSelect("全部开放等级", ["A", "B", "C"].filter((level) => all.some((item) => item.access_level === level)), state.platformAccess);
const search = create("input", "platform-filter-control");
search.type = "search";
search.name = "platform-channel-search";
search.autocomplete = "off";
search.placeholder = "搜索平台名称、国家或家纺类目…";
search.value = state.platformSearch;
toolbar.append(makeField("目标市场", marketSelect), makeField("渠道类型", typeSelect), makeField("开放等级", accessSelect), makeField("平台检索", search, "platform-filter-search"));
const header = create("section", "scene-summary platform-summary");
const grid = create("div", "platform-grid");
const apply = () => {
const keyword = normalized(state.platformSearch);
const selected = all.filter((item) => {
if (state.platformMarket && !(item.markets || []).includes(state.platformMarket)) return false;
if (state.platformType && item.type !== state.platformType) return false;
if (state.platformAccess && item.access_level !== state.platformAccess) return false;
if (!keyword) return true;
return normalized([item.name, item.region, item.type, item.textile_category, item.cross_border, ...(item.markets || []), ...(item.fit_sellers || [])].join(" ")).includes(keyword);
}).sort((a, b) => (Number(b.home_textile_sales_score || 0) + Number(b.emergence_score || 0)) - (Number(a.home_textile_sales_score || 0) + Number(a.emergence_score || 0)) || String(a.access_level).localeCompare(String(b.access_level)) || a.name.localeCompare(b.name, "zh-CN"));
header.innerHTML = "";
header.append(create("strong", "", `${selected.length}/${all.length} 个家纺销售渠道`), create("span", "", "准入条件以平台卖家中心为准"));
grid.innerHTML = "";
selected.forEach((item) => grid.appendChild(makePlatformCard(item)));
if (!selected.length) grid.appendChild(create("div", "intelligence-empty", "当前筛选条件下没有已核验平台,请调整市场或开放等级。"));
};
marketSelect.addEventListener("change", () => { state.platformMarket = marketSelect.value; apply(); });
typeSelect.addEventListener("change", () => { state.platformType = typeSelect.value; apply(); });
accessSelect.addEventListener("change", () => { state.platformAccess = accessSelect.value; apply(); });
search.addEventListener("input", () => { state.platformSearch = search.value; apply(); });
container.append(toolbar, header, grid);
apply();
}
@@ -1,5 +1,5 @@
import { state } from "./render-intelligence-state.js?v=20260814e";
import { TEXT, create, openLightbox, normalized, matchesFilters, makeVisual, makeBadge, makeExternalLink, sourceLinkLabel, currentTranslationProvider, appendTranslationLinks, appendTags, formatDate, formatVerified, makeShareButton } from "./render-intelligence-core.js?v=20260814i";
import { TEXT, create, openLightbox, normalized, matchesFilters, makeVisual, makeBadge, makeExternalLink, sourceLinkLabel, currentTranslationProvider, appendTranslationLinks, appendTags, formatDate, formatVerified, makeShareButton } from "./render-intelligence-core.js?v=20260815a";
function makeExhibitionCard(item) {
const article = create("article", "exhibition-card");
+4 -1
View File
@@ -1,7 +1,8 @@
import { state } from "./render-intelligence-state.js?v=20260814e";
import { TEXT, create, detectViewerCountry, currentTranslationProvider, formatVerified, sectionItems, matchesFilters, makeCard, renderCalendar, renderPlatforms } from "./render-intelligence-core.js?v=20260814l";
import { TEXT, create, detectViewerCountry, currentTranslationProvider, formatVerified, sectionItems, matchesFilters, makeCard, renderCalendar } from "./render-intelligence-core.js?v=20260815a";
import { renderExhibitions, renderPolicies, renderStandards, renderTrendNews, renderTrendSearchHub, trendItems } from "./render-intelligence-scenes.js?v=20260814i";
import { renderActivities } from "./render-intelligence-activities.js?v=20260814h";
import { renderPlatforms } from "./render-intelligence-platforms.js?v=20260815a";
function renderStats(data, container) {
if (!container) return;
@@ -48,6 +49,8 @@ function renderTabs(data, container, render) {
function renderRegionOptions(data, select) {
if (!select) return;
const regionField = typeof select.closest === "function" ? select.closest("label") : null;
if (regionField) regionField.hidden = state.activeSection === "calendar";
const previous = state.region;
select.innerHTML = "";
if (state.activeSection === "calendar") {
+2 -2
View File
@@ -25,7 +25,7 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Noto+Serif+SC:wght@400;700&display=swap" rel="stylesheet" />
<link href="./assets/fonts/dongguan-bold/result.css?v=20260814a" rel="stylesheet" />
<link rel="stylesheet" href="./assets/css/base.css?v=20260814h" />
<link rel="stylesheet" href="./assets/css/intelligence.css?v=20260814s" />
<link rel="stylesheet" href="./assets/css/intelligence.css?v=20260815a" />
<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"WebSite","name":"三笙AI情报局","inLanguage":"zh-CN","description":"面向家纺跨境经营团队的备货、展会、政策、平台、活动与趋势情报站"},{"@type":"Organization","name":"三笙AI情报局","alternateName":["三笙AI","家纺AI"],"url":"./"}]}</script>
</head>
<body>
@@ -78,6 +78,6 @@
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script type="module" src="./assets/js/app.js?v=20260814q"></script>
<script type="module" src="./assets/js/app.js?v=20260815a"></script>
</body>
</html>
+74 -17
View File
@@ -109,6 +109,7 @@ LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
"AU": ("Boxing Day (observed)", "英语"), "GB": ("Boxing Day (observed)", "英语"),
},
"lunar-new-year": {"CN": ("春节", "简体中文"), "KR": ("설날", "韩语"), "VN": ("Tết Nguyên Đán", "越南语"), "SG": ("农历新年 / Chinese New Year", "简体中文 / 英语")},
"new-year-eve": {"DK": ("Nytårsaften", "丹麦语"), "SE": ("Nyårsafton", "瑞典语"), "PH": ("一年中的最后一天", "英语 / 菲律宾语")},
"eid-fitr": {"AE": ("عيد الفطر", "阿拉伯语"), "TR": ("Ramazan Bayramı", "土耳其语"), "ID": ("Idul Fitri", "印度尼西亚语")},
"eid-adha": {"AE": ("عيد الأضحى", "阿拉伯语"), "TR": ("Kurban Bayramı", "土耳其语"), "ID": ("Idul Adha", "印度尼西亚语")},
"assumption-day": {
@@ -118,6 +119,11 @@ LOCAL_HOLIDAY_NAMES: dict[str, dict[str, tuple[str, str]]] = {
"PT": ("Assunção de Nossa Senhora", "葡萄牙语"), "ES": ("Asunción", "西班牙语"),
},
"indonesia-independence": {"ID": ("Hari Kemerdekaan Republik Indonesia", "印度尼西亚语")},
"new-year": {
"CN": ("元旦", "简体中文"), "JP": ("元日", "日语"), "RU": ("Новогодние каникулы", "俄语"),
},
"public-holiday:JP:coming of age day": {"JP": ("成人の日", "日语")},
"public-holiday:JP:greenery day": {"JP": ("みどりの日", "日语")},
}
FESTIVAL_TARGET_COUNTRIES = {
@@ -139,6 +145,10 @@ HOLIDAY_TRANSLATIONS = {
"whit monday": "圣灵降临节星期一", "pentecost monday": "圣灵降临节星期一",
"king's birthday": "国王诞辰", "mountain day": "山之日", "marine day": "海之日",
"respect for the aged day": "敬老日", "culture day": "文化日", "children's day": "儿童节",
"new year s day": "元旦", "new year holiday": "新年假期",
"new year s eve": "跨年夜", "last day of the year": "跨年夜",
"coming of age day": "成人节", "greenery day": "绿之日",
"labour thanksgiving day": "勤劳感谢之日", "labor thanksgiving day": "勤劳感谢之日",
}
SUBDIVISION_NAMES = {
@@ -282,6 +292,12 @@ FESTIVAL_PROFILES = {
"design": ["使用新年焕新而非单一宗教表达", "按南北半球季节调整材质"],
"avoid": ["南半球正值夏季,不要统一推保暖产品"],
},
"new-year-eve": {
"title": "跨年夜", "relevance": "low", "decision": "仅用于运营排班",
"what": "公历新年前夜或年末公共休假节点,主要影响跨年营业、物流和客服排班。",
"scenes": ["跨年营业", "物流排班"], "products": [], "design": [],
"avoid": ["不要把跨年夜与元旦或连续新年假期拆成多个重复消费节"],
},
"super-bowl": {
"title": "超级碗观赛消费节点", "relevance": "high", "decision": "值得场景备货",
"what": "美国职业橄榄球年度决赛,带动家庭观赛、聚会、酒店和城市活动消费。",
@@ -371,9 +387,11 @@ FESTIVAL_PROFILES = {
GLOBAL_AGGREGATE_KEYS = {
"black-friday", "cyber-monday", "winter-holiday", "christmas", "boxing-day",
"lunar-new-year", "eid-fitr", "eid-adha", "easter", "assumption-day", "new-year", "st-stephens-day",
"lunar-new-year", "eid-fitr", "eid-adha", "easter", "assumption-day", "new-year", "new-year-eve", "st-stephens-day",
}
RANGE_AGGREGATE_KEYS = {"new-year", "eid-fitr", "eid-adha", "lunar-new-year"}
FESTIVAL_ENGLISH_TITLES = {
"back-to-school": "Back to School",
"black-friday": "Black Friday",
@@ -385,6 +403,7 @@ FESTIVAL_ENGLISH_TITLES = {
"eid-fitr": "Eid al-Fitr",
"lunar-new-year": "Lunar New Year",
"new-year": "New Year's Day",
"new-year-eve": "New Year's Eve",
"singles-day": "Singles' Day",
"st-stephens-day": "St. Stephen's Day",
"super-bowl": "Super Bowl",
@@ -393,6 +412,7 @@ FESTIVAL_ENGLISH_TITLES = {
}
GENERIC_HOLIDAY_TITLES = {"国家纪念与公共假日", "地区公共假日"}
UNSAFE_CROSS_COUNTRY_TITLES = {"独立日", "国庆日", "共和国日", "宪法日", "解放日", "胜利日", "纪念日"}
HOLIDAY_TITLE_ALIASES = {"五一劳动节": "劳动节"}
@@ -446,7 +466,9 @@ def festival_key(name: str, source_id: str = "", country_code: str = "") -> str:
return "eid-adha"
if any(token in lower for token in ("easter", "good friday", "pentecost", "whit monday", "复活节", "耶稣受难")):
return "easter"
if any(token in lower for token in ("new year's day", "new year holiday", "元旦", "新年假期")):
if any(token in lower for token in ("new year s eve", "last day of the year", "跨年夜")):
return "new-year-eve"
if any(token in lower for token in ("new year s day", "new year holiday", "new year s holiday", "元旦", "新年假期")):
return "new-year"
if any(token in lower for token in ("memorial", "remembrance", "all souls", "追悼", "纪念")):
return "memorial"
@@ -477,7 +499,12 @@ def calendar_event_type(name: str, source_id: str = "") -> str:
def holiday_title(local_name: str, english_name: str, country_code: str) -> str:
translated = HOLIDAY_TRANSLATIONS.get(english_name.lower())
normalized = _normalized_name(english_name)
if country_code == "ID" and normalized == "independence day":
return "印度尼西亚独立日"
if country_code == "PH" and normalized == "national heroes day":
return "菲律宾国家英雄日"
translated = HOLIDAY_TRANSLATIONS.get(english_name.lower()) or HOLIDAY_TRANSLATIONS.get(_normalized_name(english_name))
if translated:
return translated
if country_code in ("CN", "JP", "KR") and local_name:
@@ -650,17 +677,20 @@ def culture_reference_url(title: str) -> str:
return f"https://zh.wikipedia.org/wiki/{quote(title.replace(' ', '_'), safe='')}"
def culture_reference_cn_url(title: str) -> str:
def culture_reference_cn_url(title: str, festival_key: str = "") -> str:
"""中国大陆访客使用国内网络更易访问的百度百科。"""
return f"https://baike.baidu.com/item/{quote(title, safe='')}"
aliases = {"new-year": "元旦"}
return f"https://baike.baidu.com/item/{quote(aliases.get(festival_key, title), safe='')}"
def visual_reference_url(title: str, country_details: list[dict[str, Any]]) -> str:
def visual_reference_url(title: str, country_details: list[dict[str, Any]], title_english: str = "") -> str:
"""图片入口只搜索节日本身,不复用来源页标题。"""
terms = [title]
for detail in country_details[:2]:
terms.extend((str(detail.get("local_name") or ""), str(detail.get("country") or "")))
query = " ".join(_dedupe(terms))
detail = next((row for row in country_details if row.get("country")), {})
query_title = title_english.strip() or str(detail.get("local_name") or title).strip()
if query_title in GENERIC_HOLIDAY_TITLES or not query_title:
query_title = str(detail.get("local_name") or title).strip()
country = str(detail.get("country_english") or detail.get("country") or "").strip()
query = " ".join(_dedupe([query_title, country, "holiday"]))[:120]
return f"https://www.bing.com/images/search?q={quote(query, safe='')}&setlang=zh-hans"
@@ -668,7 +698,11 @@ def calendar_group_identity(item: dict[str, Any]) -> tuple[str, str]:
"""同日同名的明确节日跨国家聚合;国家纪念类泛称仍按国家拆分。"""
key = str(item.get("festival_key") or "public-holiday")
title = HOLIDAY_TITLE_ALIASES.get(str(item.get("title") or "").strip(), str(item.get("title") or "").strip())
if title and title not in GENERIC_HOLIDAY_TITLES and (key.startswith("public-holiday:") or key == "memorial"):
if title and title not in GENERIC_HOLIDAY_TITLES and (
key.startswith("public-holiday:")
or key == "memorial"
or (key.startswith("national:") and title not in UNSAFE_CROSS_COUNTRY_TITLES)
):
return f"named-holiday:{_normalized_name(title)}", ""
country_partition = "" if key in GLOBAL_AGGREGATE_KEYS else str(item.get("country_code") or item.get("market_area") or item.get("region") or "")
return key, country_partition
@@ -677,6 +711,8 @@ def calendar_group_identity(item: dict[str, Any]) -> tuple[str, str]:
def calendar_group_date(item: dict[str, Any]) -> str:
"""圣诞季共用一张决策卡,其他节日仍按实际日期聚合。"""
start_date = str(item["start_date"])
if item.get("festival_key") in RANGE_AGGREGATE_KEYS:
return f"@{start_date[:4]}"
if item.get("festival_key") != "christmas":
return start_date
value = date.fromisoformat(start_date)
@@ -704,8 +740,15 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
grouped.setdefault((calendar_group_date(item), group_key, country_partition), []).append(item)
insights: list[dict[str, Any]] = []
for (start_date, group_key, _), rows in grouped.items():
rows = sorted(rows, key=lambda row: (str(row.get("start_date")) != start_date, str(row.get("start_date"))))
for (group_date, group_key, _), rows in grouped.items():
range_group = group_date.startswith("@")
start_date = min(str(row.get("start_date") or group_date) for row in rows) if range_group else group_date
rows = sorted(rows, key=lambda row: (
str(row.get("start_date")) != start_date,
0 if str(row.get("country_code") or "") == "CN" else 1,
str(row.get("country_code") or ""),
str(row.get("start_date") or ""),
))
row_keys = _dedupe([str(row.get("festival_key") or "public-holiday") for row in rows])
key = row_keys[0] if len(row_keys) == 1 else f"public-holiday:{group_key.removeprefix('named-holiday:')}"
profile = profile_for_market(key, str(rows[0].get("country_code") or ""))
@@ -715,7 +758,7 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
platforms = _dedupe([value for row in rows for value in row.get("platforms") or []])
titles = [str(row.get("title") or "") for row in rows if row.get("title")]
title = str(profile.get("title") or Counter(titles).most_common(1)[0][0])
if key.startswith("public-holiday:") or key in ("national", "memorial"):
if key.startswith(("public-holiday:", "national:")) or key in ("national", "memorial"):
title = Counter(titles).most_common(1)[0][0]
english_titles = [str(row.get("title_english") or "").strip() for row in rows if row.get("title_english")]
title_english = FESTIVAL_ENGLISH_TITLES.get(key, "")
@@ -822,6 +865,9 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"label": christmas_date_label(value, start_date) if key == "christmas" else "地区日期",
"countries": variant_countries,
})
end_date = start_date if key == "christmas" else max(str(row.get("end_date") or start_date) for row in rows)
if key == "new-year" and "RU" in country_codes:
end_date = max(end_date, f"{start_date[:4]}-01-08")
insight = {
"id": f"calendar-insight-{digest}",
"section": "calendar",
@@ -842,7 +888,7 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"change_status": "官方日期已核验",
"confidence": "高" if official_sources else "中高",
"start_date": start_date,
"end_date": start_date if key == "christmas" else max(str(row.get("end_date") or start_date) for row in rows),
"end_date": end_date,
"related_dates": related_dates,
"date_variants": date_variants,
"date_variation_note": (
@@ -876,9 +922,9 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
"days_until": (date.fromisoformat(start_date) - now).days,
"priority_score": max(int(row.get("priority_score") or 0) for row in rows) + min(12, len(countries)),
"analysis_basis": "事实层:官方日期与适用国家;分析层:家纺经营场景推断",
"visual_reference_url": visual_reference_url(title, country_details),
"visual_reference_url": visual_reference_url(title, country_details, title_english),
"culture_reference_url": culture_reference_url(title),
"culture_reference_cn_url": culture_reference_cn_url(title),
"culture_reference_cn_url": culture_reference_cn_url(title, key),
}
translation = {
key: first[key]
@@ -889,6 +935,17 @@ def aggregate_calendar_insights(items: list[dict[str, Any]], now: date, poster_f
)
if key in first
}
if "CN" in country_codes:
translation.update({
"source_language": "zh-CN",
"needs_translation": False,
"google_translation_supported": False,
"translation_url": "",
"translation_provider": "",
"translation_fallback_url": "",
"translation_fallback_provider": "",
"translation_notice": "该节日包含中国市场,中文访客直接查看中文节日资料。",
})
insight.update(translation)
insight["poster"] = poster_for(insight["id"], "calendar", insight["culture_key"])
insights.append(insight)
+7
View File
@@ -157,6 +157,8 @@ def build_nager_events(now: date, horizon: date, source: SourceSpec, result: Fet
continue
local_title = normalize_text(str(row.get("localName") or ""))
english_title = normalize_text(str(row.get("name") or local_title or "公共假日"))
if english_title.lower().replace("’", "'") == "new year's day" and day.month == 12 and day.day == 31:
continue
title = market_holiday_title(local_title, english_title, code)
subdivision_codes = [normalize_text(str(value)) for value in row.get("counties") or [] if value]
applicable_areas, country_scope = calendar_scope(bool(row.get("global", True)), subdivision_codes)
@@ -183,6 +185,11 @@ def build_nager_events(now: date, horizon: date, source: SourceSpec, result: Fet
"source_url": event_source_url,
"authority": "结构化公共假日数据",
})
item.update(translation_fields(
event_source_url,
"zh-CN" if code == "CN" else "en",
local_title or english_title,
))
items.append(enrich_market_calendar_item(item, now, code, poster_for))
return items
def build_govuk_events(now: date, horizon: date, source: SourceSpec, result: FetchResult) -> list[dict[str, Any]]:
+23 -5
View File
@@ -10,11 +10,12 @@ test("独立首页只展示家纺情报并加载独立入口", async () => {
const dongguanCss = await text("public/assets/fonts/dongguan-bold/result.css");
assert.match(html, /三笙AI情报局/);
assert.match(html, /assets\/js\/app\.js/);
assert.match(html, /assets\/js\/app\.js\?v=20260814q/);
assert.match(html, /assets\/js\/app\.js\?v=20260815a/);
assert.match(html, /assets\/css\/base\.css\?v=20260814h/);
assert.match(html, /assets\/css\/intelligence\.css\?v=20260814s/);
assert.match(html, /assets\/css\/intelligence\.css\?v=20260815a/);
assert.match(html, /<details[^>]+class="intelligence-metrics"/);
assert.match(html, /<summary[^>]+class="intelligence-utility-row"/);
assert.doesNotMatch(html, /id="intelligenceShareBtn"/);
assert.match(html, /class="tool-icon"/);
assert.match(html, /assets\/fonts\/dongguan-bold\/result\.css/);
assert.doesNotMatch(html, /cn-font\.claude-code-best\.win/);
@@ -33,7 +34,7 @@ test("数据快照不复用浏览器旧缓存", async () => {
test("七个业务入口与趋势四子类均保留", async () => {
const renderer = await text("public/assets/js/render-intelligence.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js") + await text("public/assets/js/render-intelligence-platforms.js");
for (const label of ["今日必看", "备货日历", "展会通", "政策预警", "趋势雷达", "平台渠道", "跨境活动"]) {
assert.match(modules, new RegExp(label));
}
@@ -59,6 +60,7 @@ test("所有外部业务链接使用新窗口和安全rel", async () => {
await text("public/assets/js/render-intelligence.js"),
await text("public/assets/js/render-intelligence-core.js"),
await text("public/assets/js/render-intelligence-scenes.js"),
await text("public/assets/js/render-intelligence-platforms.js"),
await text("public/assets/js/render-intelligence-activities.js"),
].join("\n");
assert.match(files, /target\s*=\s*["']_blank["']/);
@@ -67,7 +69,7 @@ test("所有外部业务链接使用新窗口和安全rel", async () => {
test("趋势分类读取真实 items 数组而不是分类对象", async () => {
const renderer = await text("public/assets/js/render-intelligence.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js") + await text("public/assets/js/render-intelligence-platforms.js");
assert.match(modules, /category\.items/);
assert.match(modules, /item\.image\s*\|\|\s*item\.thumbnail/);
assert.match(modules, /function renderCalendarMonthGrid/);
@@ -153,9 +155,23 @@ test("跨境活动按访问当天重新计算相对日期并排除已结束活
assert.equal(activityDayOffset("2026-08-15", now), 1);
});
test("日历按当前 UTC 日期优先展示未过期节点并把已过节点压到后面", async () => {
const { calendarTodayIso, sortCalendarItems, calendarDefaultItems } = await import(new URL(`../public/assets/js/render-intelligence-core.js?calendar-date=${Date.now()}`, import.meta.url));
const now = new Date("2026-08-15T00:30:00.000Z");
const items = [
{ id: "july", start_date: "2026-07-15", priority_score: 90 },
{ id: "past-august", start_date: "2026-08-10", priority_score: 90 },
{ id: "today", start_date: "2026-08-15", priority_score: 1 },
{ id: "future", start_date: "2026-08-17", priority_score: 1 },
];
assert.equal(calendarTodayIso(now), "2026-08-15");
assert.deepEqual(sortCalendarItems(items, now).map((item) => item.id), ["today", "future", "past-august", "july"]);
assert.deepEqual(calendarDefaultItems(items, 2026, now).map((item) => item.id), ["today", "future"]);
});
test("最新家纺交互与移动端契约已同步", async () => {
const renderer = await text("public/assets/js/render-intelligence.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js");
const modules = renderer + await text("public/assets/js/render-intelligence-core.js") + await text("public/assets/js/render-intelligence-scenes.js") + await text("public/assets/js/render-intelligence-platforms.js");
const baseCss = await text("public/assets/css/base.css");
const css = await text("public/assets/css/intelligence.css");
assert.doesNotMatch(renderer, /displayLimit/);
@@ -171,6 +187,8 @@ test("最新家纺交互与移动端契约已同步", async () => {
assert.doesNotMatch(modules, /家纺成熟度|新兴参考/);
assert.doesNotMatch(modules, /scope_type === "state\.region"|\["state\.region", "地区政策"\]/);
assert.match(modules, /scope_type === "region"/);
assert.match(renderer, /closest\("label"\)/);
assert.match(renderer, /regionField\.hidden\s*=\s*state\.activeSection === "calendar"/);
assert.match(css, /@media \(max-width: 680px\)/);
assert.match(css, /\.intelligence-tabs\s*\{[^}]*grid-template-columns: repeat\(3/);
assert.match(css, /min-height: 48px/);
+26
View File
@@ -146,6 +146,32 @@ class StandaloneProjectContractTests(unittest.TestCase):
if item.get("title") == title and item.get("start_date", "").startswith("2026-")
]
self.assertEqual(expected, len(matches), f"{title} 应跨国家聚合为单一日历卡片")
new_year = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "new-year" and item.get("start_date") == "2027-01-01"
]
self.assertEqual(len(new_year), 1)
self.assertGreaterEqual(new_year[0].get("end_date", ""), "2027-01-08")
self.assertLessEqual(new_year[0].get("end_date", ""), "2027-01-08")
self.assertIn("中国", new_year[0].get("affected_countries", []))
self.assertIn("俄罗斯", new_year[0].get("affected_countries", []))
self.assertFalse(new_year[0].get("needs_translation"))
new_year_eve = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "new-year-eve" and item.get("start_date") == "2026-12-31"
]
self.assertEqual(len(new_year_eve), 1)
self.assertEqual(new_year_eve[0].get("title"), "跨年夜")
self.assertFalse(any(item.get("title_english") == "New Year's Day" and item.get("start_date", "")[5:] == "12-31" for item in intelligence["calendar"]))
eid_adha = [
item for item in intelligence["calendar_insights"]
if item.get("festival_key") == "eid-adha" and item.get("start_date") == "2027-05-16"
]
self.assertEqual(len(eid_adha), 1)
self.assertGreaterEqual(eid_adha[0].get("end_date", ""), "2027-05-19")
self.assertEqual(next(item["title"] for item in intelligence["calendar_insights"] if item["id"] == "calendar-insight-4f8974ea2f346b"), "成人节")
self.assertEqual(next(item["title"] for item in intelligence["calendar_insights"] if item["id"] == "calendar-insight-87f06bf4075a17"), "绿之日")
self.assertFalse(any(item.get("title") == "国家纪念与公共假日" and item.get("title_english") for item in intelligence["calendar_insights"]))
self.assertTrue(all(item.get("title_english") for item in intelligence["calendar_insights"] if item.get("country_details")))
boxing_day = next(item for item in intelligence["calendar_insights"] if item.get("festival_key") == "boxing-day")
self.assertEqual(boxing_day["title_english"], "Boxing Day")