Skip to content

Instantly share code, notes, and snippets.

@shineli1984
Last active May 16, 2026 01:20
Show Gist options
  • Select an option

  • Save shineli1984/6c767413955b7250fa3902b5c5fef05c to your computer and use it in GitHub Desktop.

Select an option

Save shineli1984/6c767413955b7250fa3902b5c5fef05c to your computer and use it in GitHub Desktop.
V2 题词器 — 断崖式分手 心理学 5 层 (TASK-893)
<!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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
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