Last active
May 16, 2026 01:20
-
-
Save shineli1984/6c767413955b7250fa3902b5c5fef05c to your computer and use it in GitHub Desktop.
V2 题词器 — 断崖式分手 心理学 5 层 (TASK-893)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>V2 题词器 — 断崖式分手 心理学 5 层</title> | |
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", serif; | |
| background: #0a0a0a; color: #e8e8e8; | |
| height: 100vh; overflow: hidden; | |
| display: flex; flex-direction: column; | |
| } | |
| header { | |
| padding: 10px 20px; | |
| background: #1a1a1a; border-bottom: 1px solid #333; | |
| display: flex; gap: 12px; align-items: center; | |
| flex-wrap: wrap; | |
| } | |
| header h1 { font-size: 14px; color: #888; font-weight: normal; } | |
| .segs { display: flex; gap: 6px; flex-wrap: wrap; } | |
| .seg-btn { | |
| padding: 6px 10px; background: #222; border: 1px solid #444; | |
| color: #ccc; cursor: pointer; font-size: 13px; border-radius: 4px; | |
| } | |
| .seg-btn:hover { background: #333; } | |
| .seg-btn.active { background: #fde047; color: #000; border-color: #fde047; } | |
| .seg-btn.done::after { content: " ✓"; color: #4ade80; } | |
| .controls { display: flex; gap: 8px; align-items: center; margin-left: auto; } | |
| .controls button { | |
| padding: 8px 14px; background: #2a2a2a; border: 1px solid #444; | |
| color: #fff; cursor: pointer; border-radius: 4px; font-size: 13px; | |
| } | |
| .controls button:hover { background: #3a3a3a; } | |
| .controls button.primary { background: #fde047; color: #000; border-color: #fde047; font-weight: bold; } | |
| .controls input[type="range"] { width: 120px; } | |
| .controls label { font-size: 12px; color: #888; } | |
| .speed-display { color: #fde047; font-weight: bold; min-width: 60px; } | |
| .timer { color: #888; font-size: 13px; font-family: monospace; min-width: 100px; text-align: right; } | |
| main { | |
| flex: 1; overflow-y: auto; padding: 60px 80px; | |
| scroll-behavior: smooth; | |
| line-height: 1.9; font-size: 52px; font-weight: 500; | |
| } | |
| .phrase { | |
| display: inline; padding: 2px 6px; border-radius: 4px; | |
| color: #555; transition: color 0.2s, background 0.2s; | |
| } | |
| .phrase.upcoming { color: #888; } | |
| .phrase.current { | |
| color: #000; background: #fde047; | |
| box-shadow: 0 0 30px rgba(253, 224, 71, 0.4); | |
| } | |
| .phrase.past { color: #444; } | |
| .slow { | |
| letter-spacing: 0.3em; color: #fde047; font-weight: 700; | |
| border-bottom: 2px solid #fde047; | |
| padding: 0 4px; | |
| } | |
| .phrase.current .slow { color: #000; background: #fff4a3; border-bottom-color: #000; } | |
| .pause { | |
| display: inline-block; color: #fde047; font-weight: 700; | |
| margin: 0 4px; opacity: 0.6; | |
| } | |
| footer { | |
| padding: 10px 20px; background: #1a1a1a; border-top: 1px solid #333; | |
| font-size: 12px; color: #666; display: flex; gap: 20px; | |
| } | |
| .progress { | |
| height: 4px; background: #1a1a1a; | |
| } | |
| .progress-bar { height: 100%; background: #fde047; width: 0%; transition: width 0.1s linear; } | |
| .seg-info { color: #888; font-size: 12px; padding: 0 20px 0 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <h1>V2 题词器</h1> | |
| <div class="segs" id="segs"></div> | |
| <div class="controls"> | |
| <label>语速</label> | |
| <input type="range" id="speed" min="4.0" max="8.0" step="0.1" value="6.44"> | |
| <span class="speed-display" id="speedDisplay">6.44 字/秒</span> | |
| <button id="restart">↺ 重置</button> | |
| <button id="play" class="primary">▶ 开始</button> | |
| <span class="timer" id="timer">00:00 / 00:00</span> | |
| <button id="markDone">标记已录</button> | |
| </div> | |
| </header> | |
| <div class="progress"><div class="progress-bar" id="progressBar"></div></div> | |
| <main id="main"> | |
| <div id="text"></div> | |
| </main> | |
| <footer> | |
| <span class="seg-info" id="segInfo"></span> | |
| <span>· 空格键 = 开始/暂停 · R = 重置 · ←/→ = 切段</span> | |
| <span style="margin-left:auto; color:#444;">本地播放, 不上传任何录像</span> | |
| </footer> | |
| <script> | |
| const SEGMENTS = [ | |
| { | |
| name: "钩子 + preview", | |
| text: "问你个问题啊. 你有没有经历过\"断-崖-式-分-手\"... 换言之就是, TA 为什么会突-然-否-认-整-段-感-情, 就不能坐下来好好聊为什么要分开. 今天我们就从心理学的角度来分析, 突-然-不-爱-了的四种原因或是类型. 他们分别是前兆信号, 依-恋-关-闭, 人-格-切-分, 认-知-重-写, 以及我认为的解药 — 他-人-即-地-狱." | |
| }, | |
| { | |
| name: "第一层 — 前兆信号 (行为表象)", | |
| text: "我之前看过戈特曼的一段访谈, 他在华盛顿做了 40 多年的婚姻研究, 实验室叫爱情实验室, 把一对夫妻拉来吵 15 分钟, 然后预测他们的离婚准确率高达 94%. 这个预测只是识别了 4 个信号就能这么准确. 约翰·戈特曼在他 1999 年那本《幸福婚姻的七个法则》中就总结了这 4 个信号 — 他们分别是批评... 蔑视... 防御... 筑墙. 戈特曼着重提出的是蔑视. 他是离-婚-的-最-强-信-号. 回想一下, 在你的亲密关系中是否出现了这个信号, 而你并没有重视它的出现. 但同样是这四个信号, 有人愿意吵到天亮, 有人突然不爱. 差异在哪里? — 这就不得不提到第二个可能性, 依-恋-关-闭." | |
| }, | |
| { | |
| name: "第二个角度 — 鲍尔比 回避型依恋 (依恋系统)", | |
| text: "回避型依恋这个词可能你已不再陌生. 在 1987 的成人研究中, 发现大概 25% 的成年人是回避型, 也就是说你身边每 4 个人就有 1 个. 而回避型最大的特征其实就是所谓的依-恋-关-闭. 约翰·鲍尔比在 1969 年写过《依恋》中有提到过. 他和同事詹姆斯·罗伯逊在同年拍过一部纪录片, 拍的是一个 17 个月大的小男孩约翰, 他在母亲不能照顾的情况下被寄养了 9 天. 这 9 天里小男孩走完了三个阶段 — 抗-议... 绝-望... 疏-离. 第九天他的母亲来接他的时候, 他不肯看妈妈也不肯被抱. 他是真的适应了, 而是把\"想要妈妈\"这件事整个完全隔离了. 从分手的角度来说就是把\"想靠近一个人\"这种需求进行封闭. 我不知道大家有没有听过一种说法, 就是在分手之后不痛和感受不到痛是不一样的. 前-者-是-释-怀-了, 而后-者-就-是-疏-离. 这就是为什么回避型最戏剧化的表现其实就是所谓的断崖式分手. 把\"我-曾-经-爱-过-TA\"这件事完全屏蔽. 但是还有一种更窄的人格底色 — 不存在于依恋系统里, 而在更深的人-格-结-构里." | |
| }, | |
| { | |
| name: "第三层 — 肯伯格 客体分裂 (人格结构)", | |
| text: "第三种原因. 我们叫人-格-切-分. 在我看来这是最反直觉的一种. 奥托·肯伯格, 在 1975 年的《边缘型条件与病理性自恋》中提出这种防御机制叫分-裂, 换言之就是把同一个人切成\"全-好\"和\"全-坏\"两半. 和有这种人格结构的人谈恋爱, 就好像他手上拿着一个神坛, 一开始被你吸引的时候他会把你放到神坛上面. 眼里你在发光, 这种状态其实就是所谓的理-想-化. 他只是看到了他想要的你. 你只要不那么像他想象中的那样完美, 那这个神坛就会坍-塌. 一些小事儿. 比方说你迟到 5 分钟, 你那天没发早安. 这就能够让你从\"全好\"被切换到\"全坏\"了. 他没有办法真正的喜欢你. 因为真正的爱是看得见你的不-完-美还愿意跟你在一起. 那你可能就要问了 — 过去那个\"全好\"怎么办? 这之中会有一部分人一旦切到\"全坏\", 之-前-的-全-好会被全-部-忘-掉. 这就是另一个现象叫认-知-失-调." | |
| }, | |
| { | |
| name: "第四层 — 费斯廷格 认知失调 (认知防御)", | |
| text: "利昂·费斯廷格 1957 年写过一本书叫《认知失调论》, 核心就一句话 — 你做的事和你心里相信的对不上的时候, 它会在你不自觉的情况下重写其中一个让两边对齐. 这时就产生了认-知-失-调. 或者更准确一点讲就是认-知-重-写. 我这里说的重写和说谎有着本质区别. 重写是 TA 自己的记忆已经被更改, 但自己完全不知道改过. 这是不是很可怕. 这类人的突然不爱了就是, 一边是\"你侬我侬\", 一边是\"现实中的反差\", 他大脑没法同时兼顾两者, 必须把第一件改写成... \"我-们-并-没-有-怎-么-亲-密-过\". 最后我们怎么能从这样的\"分开\"中走出来? 我认为萨-特的观点就是一个很好的解药." | |
| }, | |
| { | |
| name: "第五层 — 萨特 他人即地狱 (存在自由)", | |
| text: "1944 年萨特在《禁闭》这本书中写道... \"他-人-即-地-狱\". 这句话其实就是所谓的, 一旦你被另一个人的目光定义成某种角色, 你就失去了\"自-己-是-谁\"的自由. 换言之就是被对方当成\"好女友\", 当成\"可靠的伴侣\", 听起来其实很温暖, 对吧. 但这也可能就是一个牢-笼. 我们将这两者分开看. 因为对方眼中的你往往一开始并不是那个真正的你. 所以温-暖是他看见了你还允许你改变; 而牢-笼则是他看见了你, 然后不希望你再改变. 这时候的\"温暖\"就是一种假象, 一种禁-锢. 你就应该选择转-身-离-开, 重-获-自-由." | |
| }, | |
| { | |
| name: "结尾", | |
| text: "好... 今天就讲到这里. 打-完-收-工." | |
| }, | |
| ]; | |
| // Split a segment text into phrases by punctuation | |
| function splitPhrases(text) { | |
| // Split keeping the delimiter attached to the prev phrase | |
| const re = /([^。!?;?!.;]*[。!?;?!.;])/g; | |
| let phrases = text.match(re) || []; | |
| // Also split by ', ' and '— ' for finer pacing | |
| const refined = []; | |
| for (const p of phrases) { | |
| // further split on commas | |
| const sub = p.match(/[^,,]+[,,]?/g) || [p]; | |
| refined.push(...sub.filter(x => x.trim())); | |
| } | |
| if (refined.length === 0 && text.trim()) refined.push(text); | |
| return refined.map(s => s.trim()).filter(Boolean); | |
| } | |
| // Count weighted chars for timing. | |
| // - hyphenated runs (X-X-X-X) = each Chinese char counts 2x (slow / emphatic) | |
| // - "..." = pause worth 6.44 char-units (≈ 1s at default pace) | |
| // - normal Chinese char = 1 | |
| // - English word = 2 | |
| // - digit group = 1.5 | |
| function countSpoken(s) { | |
| let total = 0; | |
| let remaining = s; | |
| // Pauses: each "..." = 6.44 char-units | |
| const pauses = (remaining.match(/\.{3,}/g) || []).length; | |
| total += pauses * 6.44; | |
| remaining = remaining.replace(/\.{3,}/g, ""); | |
| // Hyphenated emphasis runs: each Chinese char counts 2x | |
| const slowRuns = remaining.match(/[一-鿿](?:-[一-鿿])+/g) || []; | |
| for (const run of slowRuns) { | |
| const charCount = run.replace(/-/g, "").length; | |
| total += charCount * 2; | |
| remaining = remaining.replace(run, ""); | |
| } | |
| const cn = (remaining.match(/[一-鿿]/g) || []).length; | |
| const enWords = (remaining.match(/[a-zA-Z]+/g) || []).length; | |
| const digitGroups = (remaining.match(/\d+/g) || []).length; | |
| total += cn + enWords * 2 + digitGroups * 1.5; | |
| return total; | |
| } | |
| const state = { | |
| segIdx: 0, | |
| phrases: [], | |
| phraseDurations: [], | |
| totalDur: 0, | |
| playing: false, | |
| elapsed: 0, | |
| lastTick: 0, | |
| rafId: null, | |
| done: new Set(), | |
| }; | |
| const els = { | |
| segs: document.getElementById("segs"), | |
| text: document.getElementById("text"), | |
| play: document.getElementById("play"), | |
| restart: document.getElementById("restart"), | |
| speed: document.getElementById("speed"), | |
| speedDisplay: document.getElementById("speedDisplay"), | |
| timer: document.getElementById("timer"), | |
| progressBar: document.getElementById("progressBar"), | |
| segInfo: document.getElementById("segInfo"), | |
| markDone: document.getElementById("markDone"), | |
| main: document.getElementById("main"), | |
| }; | |
| function buildSegButtons() { | |
| els.segs.innerHTML = ""; | |
| SEGMENTS.forEach((s, i) => { | |
| const b = document.createElement("button"); | |
| b.className = "seg-btn" + (i === state.segIdx ? " active" : "") + (state.done.has(i) ? " done" : ""); | |
| b.textContent = (i + 1) + ". " + s.name; | |
| b.onclick = () => loadSegment(i); | |
| els.segs.appendChild(b); | |
| }); | |
| } | |
| function loadSegment(idx) { | |
| state.segIdx = idx; | |
| state.elapsed = 0; | |
| state.playing = false; | |
| if (state.rafId) cancelAnimationFrame(state.rafId); | |
| const seg = SEGMENTS[idx]; | |
| state.phrases = splitPhrases(seg.text); | |
| const speed = parseFloat(els.speed.value); | |
| state.phraseDurations = state.phrases.map(p => countSpoken(p) / speed); | |
| state.totalDur = state.phraseDurations.reduce((a, b) => a + b, 0); | |
| renderText(); | |
| buildSegButtons(); | |
| updateUI(); | |
| els.play.textContent = "▶ 开始"; | |
| els.segInfo.textContent = `段 ${idx + 1}/${SEGMENTS.length} — ${countSpoken(seg.text).toFixed(0)} 字 · ${state.totalDur.toFixed(0)} 秒`; | |
| } | |
| function escapeHtml(s) { | |
| return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); | |
| } | |
| function renderPhraseHTML(p) { | |
| // Wrap hyphenated slow runs in <span class="slow"> (keep hyphens visible) | |
| // Wrap "..." in <span class="pause"> | |
| let h = escapeHtml(p); | |
| h = h.replace(/[一-鿿](?:-[一-鿿])+/g, m => `<span class="slow">${m}</span>`); | |
| h = h.replace(/\.{3,}/g, m => `<span class="pause">${m}</span>`); | |
| return h; | |
| } | |
| function renderText() { | |
| els.text.innerHTML = ""; | |
| state.phrases.forEach((p, i) => { | |
| const span = document.createElement("span"); | |
| span.className = "phrase upcoming"; | |
| span.dataset.idx = i; | |
| span.innerHTML = renderPhraseHTML(p); | |
| els.text.appendChild(span); | |
| els.text.appendChild(document.createTextNode(" ")); | |
| }); | |
| } | |
| function fmtTime(s) { | |
| s = Math.max(0, s); | |
| const m = Math.floor(s / 60); | |
| const ss = Math.floor(s % 60); | |
| return `${String(m).padStart(2,"0")}:${String(ss).padStart(2,"0")}`; | |
| } | |
| function updateUI() { | |
| els.timer.textContent = `${fmtTime(state.elapsed)} / ${fmtTime(state.totalDur)}`; | |
| const pct = state.totalDur > 0 ? (state.elapsed / state.totalDur) * 100 : 0; | |
| els.progressBar.style.width = Math.min(100, pct) + "%"; | |
| // Update phrase highlights | |
| let acc = 0, currentIdx = -1; | |
| for (let i = 0; i < state.phraseDurations.length; i++) { | |
| if (state.elapsed >= acc && state.elapsed < acc + state.phraseDurations[i]) { | |
| currentIdx = i; | |
| break; | |
| } | |
| acc += state.phraseDurations[i]; | |
| } | |
| if (currentIdx === -1 && state.elapsed >= state.totalDur) currentIdx = state.phrases.length; | |
| document.querySelectorAll(".phrase").forEach((el, i) => { | |
| el.classList.remove("upcoming", "current", "past"); | |
| if (i < currentIdx) el.classList.add("past"); | |
| else if (i === currentIdx) el.classList.add("current"); | |
| else el.classList.add("upcoming"); | |
| }); | |
| // Auto-scroll current into view | |
| const cur = document.querySelector(".phrase.current"); | |
| if (cur) { | |
| const rect = cur.getBoundingClientRect(); | |
| const mainRect = els.main.getBoundingClientRect(); | |
| if (rect.top < mainRect.top + 100 || rect.bottom > mainRect.bottom - 100) { | |
| cur.scrollIntoView({ behavior: "smooth", block: "center" }); | |
| } | |
| } | |
| } | |
| function tick(ts) { | |
| if (!state.playing) return; | |
| if (!state.lastTick) state.lastTick = ts; | |
| const dt = (ts - state.lastTick) / 1000; | |
| state.lastTick = ts; | |
| state.elapsed += dt; | |
| if (state.elapsed >= state.totalDur) { | |
| state.elapsed = state.totalDur; | |
| state.playing = false; | |
| els.play.textContent = "▶ 重播"; | |
| } | |
| updateUI(); | |
| if (state.playing) state.rafId = requestAnimationFrame(tick); | |
| } | |
| function togglePlay() { | |
| if (state.elapsed >= state.totalDur) { | |
| state.elapsed = 0; | |
| updateUI(); | |
| } | |
| state.playing = !state.playing; | |
| els.play.textContent = state.playing ? "⏸ 暂停" : "▶ 开始"; | |
| if (state.playing) { | |
| state.lastTick = 0; | |
| state.rafId = requestAnimationFrame(tick); | |
| } else { | |
| if (state.rafId) cancelAnimationFrame(state.rafId); | |
| } | |
| } | |
| function restart() { | |
| state.elapsed = 0; | |
| state.playing = false; | |
| if (state.rafId) cancelAnimationFrame(state.rafId); | |
| els.play.textContent = "▶ 开始"; | |
| els.main.scrollTo({ top: 0, behavior: "smooth" }); | |
| updateUI(); | |
| } | |
| // Speed change recalculates durations | |
| els.speed.oninput = () => { | |
| const speed = parseFloat(els.speed.value); | |
| els.speedDisplay.textContent = speed.toFixed(2) + " 字/秒"; | |
| state.phraseDurations = state.phrases.map(p => countSpoken(p) / speed); | |
| state.totalDur = state.phraseDurations.reduce((a, b) => a + b, 0); | |
| // Clamp elapsed to new total | |
| if (state.elapsed > state.totalDur) state.elapsed = state.totalDur; | |
| updateUI(); | |
| els.segInfo.textContent = `段 ${state.segIdx + 1}/${SEGMENTS.length} — ${countSpoken(SEGMENTS[state.segIdx].text).toFixed(0)} 字 · ${state.totalDur.toFixed(0)} 秒`; | |
| }; | |
| els.play.onclick = togglePlay; | |
| els.restart.onclick = restart; | |
| els.markDone.onclick = () => { | |
| if (state.done.has(state.segIdx)) state.done.delete(state.segIdx); | |
| else state.done.add(state.segIdx); | |
| localStorage.setItem("v2-tp-done", JSON.stringify([...state.done])); | |
| buildSegButtons(); | |
| }; | |
| document.addEventListener("keydown", e => { | |
| if (e.code === "Space") { e.preventDefault(); togglePlay(); } | |
| else if (e.key === "r" || e.key === "R") restart(); | |
| else if (e.key === "ArrowLeft" && state.segIdx > 0) loadSegment(state.segIdx - 1); | |
| else if (e.key === "ArrowRight" && state.segIdx < SEGMENTS.length - 1) loadSegment(state.segIdx + 1); | |
| }); | |
| // Load done state | |
| try { | |
| const saved = JSON.parse(localStorage.getItem("v2-tp-done") || "[]"); | |
| saved.forEach(i => state.done.add(i)); | |
| } catch {} | |
| loadSegment(0); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment