app.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. (function () {
  2. const data = window.CASE_DATA;
  3. const timeline = document.querySelector("#timeline");
  4. const filters = document.querySelector("#filters");
  5. let activeFilter = "all";
  6. let timelineView = "compact";
  7. let editing = false;
  8. const editStoreKey = "saigon-leverage-edits-v1";
  9. const themeStoreKey = "saigon-leverage-theme";
  10. document.querySelector("#episodeLabel").textContent = `CASE TIMELINE · ${data.episode}`;
  11. document.querySelector("#heroEyebrow").textContent = data.eyebrow;
  12. document.querySelector("#caseTitle").innerHTML = `${data.title.split(":")[0]}:<br><span>${data.title.split(":").slice(1).join(":")}</span>`;
  13. document.querySelector("#heroDeck").textContent = data.deck;
  14. document.querySelector("#heroDeck").dataset.edit = "hero-deck";
  15. document.querySelector("#heroMetrics").innerHTML = data.metrics.map((metric) => `
  16. <div class="metric"><strong data-edit="metric-${metric.label}-value">${metric.value}</strong><span data-edit="metric-${metric.label}-label">${metric.label}</span></div>
  17. `).join("");
  18. document.querySelector("#participantsGrid").innerHTML = data.participants.map((party) => `
  19. <article class="participant-card">
  20. <div class="participant-title"><span aria-hidden="true">${party.mark}</span><div><h3 data-edit="party-${party.mark}-name">${party.name}</h3><p data-edit="party-${party.mark}-role">${party.role}</p></div></div>
  21. <p data-edit="party-${party.mark}-stance">${party.stance}</p>
  22. <strong data-edit="party-${party.mark}-asset">${party.asset}</strong>
  23. </article>
  24. `).join("");
  25. function applyTimelineView() {
  26. timeline.classList.toggle("compact", timelineView === "compact");
  27. document.querySelectorAll("[data-view]").forEach((button) => {
  28. const active = button.dataset.view === timelineView;
  29. button.classList.toggle("active", active);
  30. button.setAttribute("aria-pressed", String(active));
  31. });
  32. }
  33. function renderFilters() {
  34. filters.innerHTML = data.categories.map((item) => `
  35. <button type="button" class="filter ${item.id === activeFilter ? "active" : ""}" data-filter="${item.id}">
  36. <i aria-hidden="true"></i>${item.label}
  37. </button>
  38. `).join("");
  39. }
  40. function renderTimeline() {
  41. const visible = activeFilter === "all" ? data.events : data.events.filter((event) => event.category === activeFilter);
  42. timeline.innerHTML = visible.map((event, position) => `
  43. <article class="event ${event.featured ? "featured" : ""}" id="${event.id}" data-category="${event.category}" style="--delay:${position * 35}ms">
  44. <div class="event-date"><span data-edit="event-${event.id}-date">${event.date}</span><small>${event.index}</small></div>
  45. <button class="event-card" type="button" aria-expanded="false" aria-controls="detail-${event.id}">
  46. <div class="event-card-top"><span class="tag">${categoryLabel(event.category)}</span><span class="expand-icon" aria-hidden="true">+</span></div>
  47. <h3 data-edit="event-${event.id}-title">${event.title}</h3>
  48. <p data-edit="event-${event.id}-summary">${event.summary}</p>
  49. <strong class="event-stat" data-edit="event-${event.id}-stat">${event.stat}</strong>
  50. <div class="event-detail" id="detail-${event.id}">
  51. <div><span>商业含义</span><p data-edit="event-${event.id}-detail">${event.detail}</p></div>
  52. <div><span>关键影响</span><p data-edit="event-${event.id}-impact">${event.impact}</p></div>
  53. </div>
  54. </button>
  55. </article>
  56. `).join("");
  57. bindCards();
  58. applyTimelineView();
  59. restoreEdits();
  60. if (editing) setEditing(true);
  61. }
  62. function categoryLabel(id) {
  63. return data.categories.find((item) => item.id === id)?.label || id;
  64. }
  65. function bindCards() {
  66. document.querySelectorAll(".event-card").forEach((card) => {
  67. card.addEventListener("click", () => {
  68. if (editing) return;
  69. const expanded = card.getAttribute("aria-expanded") === "true";
  70. card.setAttribute("aria-expanded", String(!expanded));
  71. card.closest(".event").classList.toggle("open", !expanded);
  72. });
  73. });
  74. }
  75. filters.addEventListener("click", (event) => {
  76. const button = event.target.closest("[data-filter]");
  77. if (!button) return;
  78. activeFilter = button.dataset.filter;
  79. renderFilters();
  80. renderTimeline();
  81. });
  82. document.querySelector(".view-switch").addEventListener("click", (event) => {
  83. const button = event.target.closest("[data-view]");
  84. if (!button) return;
  85. timelineView = button.dataset.view;
  86. applyTimelineView();
  87. });
  88. document.querySelector("#chapterNav").innerHTML = data.chapters.map((chapter) => {
  89. const target = data.events.find((event) => event.chapter === chapter.id)?.id;
  90. return `<a href="#${target}"><span>${chapter.label.slice(0, 2)}</span>${chapter.label.slice(3)}</a>`;
  91. }).join("");
  92. document.querySelector("#modelGrid").innerHTML = data.model.map((item) => `
  93. <article class="model-card ${item.category}"><span>${item.num}</span><h3 data-edit="model-${item.num}-title">${item.title}</h3><strong data-edit="model-${item.num}-subtitle">${item.subtitle}</strong><p data-edit="model-${item.num}-body">${item.body}</p></article>
  94. `).join("");
  95. function storedEdits() {
  96. try { return JSON.parse(localStorage.getItem(editStoreKey)) || {}; } catch (_) { return {}; }
  97. }
  98. function restoreEdits() {
  99. const saved = storedEdits();
  100. document.querySelectorAll("[data-edit]").forEach((element) => {
  101. if (saved[element.dataset.edit] !== undefined) element.innerHTML = saved[element.dataset.edit];
  102. });
  103. }
  104. function saveEdit(element) {
  105. const saved = storedEdits();
  106. saved[element.dataset.edit] = element.innerHTML;
  107. localStorage.setItem(editStoreKey, JSON.stringify(saved));
  108. }
  109. function setEditing(next) {
  110. editing = next;
  111. document.body.classList.toggle("is-editing", editing);
  112. const editButton = document.querySelector("#editToggle");
  113. editButton.querySelector(".icon-glyph").textContent = editing ? "✓" : "✎";
  114. editButton.setAttribute("aria-label", editing ? "完成编辑" : "编辑页面内容");
  115. editButton.title = editing ? "完成编辑" : "编辑页面内容";
  116. document.querySelectorAll("[data-edit]").forEach((element) => {
  117. element.contentEditable = editing ? "true" : "false";
  118. element.spellcheck = editing;
  119. });
  120. }
  121. document.addEventListener("input", (event) => {
  122. const editable = event.target.closest("[data-edit]");
  123. if (editing && editable) saveEdit(editable);
  124. });
  125. document.querySelector("#editToggle").addEventListener("click", () => setEditing(!editing));
  126. document.querySelector("#resetEdits").addEventListener("click", () => {
  127. if (!confirm("确定恢复全部原稿吗?当前编辑内容将被清除。")) return;
  128. localStorage.removeItem(editStoreKey);
  129. location.reload();
  130. });
  131. function setTheme(theme) {
  132. document.documentElement.dataset.theme = theme;
  133. const themeButton = document.querySelector("#themeToggle");
  134. const nextTheme = theme === "dark" ? "浅色" : "深色";
  135. themeButton.querySelector(".icon-glyph").textContent = theme === "dark" ? "☼" : "☾";
  136. themeButton.setAttribute("aria-label", `切换至${nextTheme}模式`);
  137. themeButton.title = `切换至${nextTheme}模式`;
  138. document.querySelectorAll("img[data-dark-src]").forEach((image) => {
  139. image.src = theme === "dark" ? image.dataset.darkSrc : image.dataset.lightSrc;
  140. });
  141. localStorage.setItem(themeStoreKey, theme);
  142. }
  143. document.querySelector("#themeToggle").addEventListener("click", () => {
  144. setTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
  145. });
  146. document.querySelector("#copyLink").addEventListener("click", async (event) => {
  147. const button = event.currentTarget;
  148. try {
  149. await navigator.clipboard.writeText(location.href);
  150. button.classList.add("is-copied");
  151. button.querySelector(".icon-glyph").textContent = "✓";
  152. button.setAttribute("aria-label", "链接已复制");
  153. button.title = "链接已复制";
  154. setTimeout(() => {
  155. button.classList.remove("is-copied");
  156. button.querySelector(".icon-glyph").textContent = "⤴";
  157. button.setAttribute("aria-label", "复制当前页面链接");
  158. button.title = "复制当前页面链接";
  159. }, 1600);
  160. } catch (_) {
  161. button.querySelector(".icon-glyph").textContent = "!";
  162. button.setAttribute("aria-label", "复制失败");
  163. button.title = "复制失败";
  164. }
  165. });
  166. function updateProgress() {
  167. const story = document.querySelector("#story");
  168. const start = story.offsetTop;
  169. const distance = Math.max(story.offsetHeight - innerHeight, 1);
  170. const percent = Math.max(0, Math.min(100, ((scrollY - start) / distance) * 100));
  171. document.querySelector("#progressBar").style.width = `${percent}%`;
  172. document.querySelector("#progressText").textContent = `${Math.round(percent)}%`;
  173. }
  174. addEventListener("scroll", updateProgress, { passive: true });
  175. renderFilters();
  176. renderTimeline();
  177. restoreEdits();
  178. setTheme(localStorage.getItem(themeStoreKey) || "dark");
  179. updateProgress();
  180. })();