Files

1273 lines
55 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
const SAMPLE_HISTORY = [
{
id: "sample-1",
date: "今天 · 21:10",
lines: [9, 8, 8, 8, 8, 8],
question: SAMPLE_QUESTION,
explanationSource: "AI 生成",
action: "写下三条已知事实,再约一次不超过 20 分钟的信息沟通。"
},
{
id: "sample-2",
date: "昨天 · 07:40",
lines: [8, 8, 7, 8, 8, 8],
question: null,
explanationSource: "本地解读",
action: null
},
{
id: "sample-3",
date: "7 月 30 日 · 22:15",
lines: [7, 7, 8, 8, 7, 6],
question: "这段合作应该怎样调整边界?",
explanationSource: null,
action: "先把需要确认的职责写成一页清单。"
}
];
const HEXAGRAM_NAMES = [
"", "乾", "坤", "屯", "蒙", "需", "讼", "师", "比", "小畜", "履", "泰", "否",
"同人", "大有", "谦", "豫", "随", "蛊", "临", "观", "噬嗑", "贲", "剥", "复",
"无妄", "大畜", "颐", "大过", "坎", "离", "咸", "恒", "遁", "大壮", "晋",
"明夷", "家人", "睽", "蹇", "解", "损", "益", "夬", "姤", "萃", "升", "困",
"井", "革", "鼎", "震", "艮", "渐", "归妹", "丰", "旅", "巽", "兑", "涣",
"节", "中孚", "小过", "既济", "未济"
];
// 行为下卦、列为上卦;三位编码均按自下而上排列,1 为阳、0 为阴。
const KING_WEN = {
"111": { "111": 1, "100": 34, "010": 5, "001": 26, "000": 11, "011": 9, "101": 14, "110": 43 },
"100": { "111": 25, "100": 51, "010": 3, "001": 27, "000": 24, "011": 42, "101": 21, "110": 17 },
"010": { "111": 6, "100": 40, "010": 29, "001": 4, "000": 7, "011": 59, "101": 64, "110": 47 },
"001": { "111": 33, "100": 62, "010": 39, "001": 52, "000": 15, "011": 53, "101": 56, "110": 31 },
"000": { "111": 12, "100": 16, "010": 8, "001": 23, "000": 2, "011": 20, "101": 35, "110": 45 },
"011": { "111": 44, "100": 32, "010": 48, "001": 18, "000": 46, "011": 57, "101": 50, "110": 28 },
"101": { "111": 13, "100": 55, "010": 63, "001": 22, "000": 36, "011": 37, "101": 30, "110": 49 },
"110": { "111": 10, "100": 54, "010": 60, "001": 41, "000": 19, "011": 61, "101": 38, "110": 58 }
};
const icons = {
lock: '<svg aria-hidden="true" viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M8.5 10V7.5a3.5 3.5 0 017 0V10"/></svg>',
arrow: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M5 12h14M14 7l5 5-5 5"/></svg>',
chevron: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M9 6l6 6-6 6"/></svg>',
shield: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M12 3l7 3v5c0 4.7-2.8 8-7 10-4.2-2-7-5.3-7-10V6l7-3z"/><path d="M9.2 12l1.8 1.8 3.9-4"/></svg>',
book: '<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M4 5.5A2.5 2.5 0 016.5 3H11v16H6.5A2.5 2.5 0 004 21.5v-16zM20 5.5A2.5 2.5 0 0017.5 3H13v16h4.5a2.5 2.5 0 012.5 2.5v-16z"/></svg>'
};
const screen = document.querySelector("#screen");
const modalRoot = document.querySelector("#modal-root");
const toastRegion = document.querySelector("#toast-region");
const backButton = document.querySelector("[data-action='back']");
const state = {
screen: "welcome",
question: "",
lines: [],
coins: [null, null, null],
modal: null,
consent: false,
explanationSource: "ai",
aiError: false,
historyRecords: [],
selectedRecordId: null,
autoSaveHistory: true,
saveQuestion: true,
saveExplanation: true,
saveAction: true,
saved: false,
currentRecordId: "current-session",
currentSessionOptedOut: false,
currentSaveMode: null,
explanationGenerated: false,
questionOrigin: "welcome",
settingsOrigin: "home",
deleteReturnScreen: "history"
};
function sampleHistory() {
return SAMPLE_HISTORY.map((record) => ({ ...record, lines: [...record.lines] }));
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function lineIsYang(value) {
return value === 7 || value === 9;
}
function lineIsMoving(value) {
return value === 6 || value === 9;
}
function transformLines(lines) {
return lines.map((value) => {
if (value === 6) return 7;
if (value === 9) return 8;
return value;
});
}
function lookupHexagram(lines) {
const bits = lines.map((value) => lineIsYang(value) ? "1" : "0");
const lower = bits.slice(0, 3).join("");
const upper = bits.slice(3, 6).join("");
const number = KING_WEN[lower][upper];
return { number, name: HEXAGRAM_NAMES[number] };
}
function lineName(value, index) {
const position = ["初", "二", "三", "四", "五", "上"][index];
const polarity = lineIsYang(value) ? "九" : "六";
if (index === 0) return `${position}${polarity}`;
if (index === 5) return `${position}${polarity}`;
return `${polarity}${position}`;
}
function resultData() {
const sourceLines = state.lines.length === 6 ? state.lines : SAMPLE_LINES;
const changedLines = transformLines(sourceLines);
return {
sourceLines,
changedLines,
primary: lookupHexagram(sourceLines),
changed: lookupHexagram(changedLines),
movingIndexes: sourceLines
.map((value, index) => lineIsMoving(value) ? index : -1)
.filter((index) => index >= 0)
};
}
function hasSavableQuestion() {
return Boolean(state.question && !state.question.startsWith("未记录具体问题"));
}
function generatedExplanationSource() {
if (!state.explanationGenerated || !state.saveExplanation) return null;
return state.explanationSource === "ai" ? "AI 生成" : "本地解读";
}
function currentSessionRecord() {
return state.historyRecords.find((record) => record.id === state.currentRecordId);
}
function persistCurrentSession(mode = "manual") {
if (state.lines.length !== 6) return;
const data = resultData();
const existing = currentSessionRecord();
const record = {
id: state.currentRecordId,
date: "刚刚",
lines: [...data.sourceLines],
question: state.saveQuestion && hasSavableQuestion() ? state.question : null,
explanationSource: generatedExplanationSource(),
action: state.explanationGenerated && state.saveAction
? "写下三条已知事实,再进行一次短沟通。"
: null
};
if (existing) Object.assign(existing, record);
else state.historyRecords.unshift(record);
state.saved = true;
state.currentSessionOptedOut = false;
state.currentSaveMode = mode;
}
function attachExplanationToCurrentSession() {
const record = currentSessionRecord();
if (!record || !state.saved) return;
if (state.saveExplanation) record.explanationSource = generatedExplanationSource();
if (state.saveAction) {
record.action = "写下三条已知事实,再进行一次短沟通。";
}
}
function prepareNewSession() {
state.currentRecordId = `current-session-${Date.now()}-${state.historyRecords.length}`;
state.saved = false;
state.currentSessionOptedOut = false;
state.currentSaveMode = null;
state.explanationGenerated = false;
state.aiError = false;
}
function stepper(current, total) {
const progress = Math.round((current / total) * 100);
return `
<div class="stepper" aria-label="第 ${current} 步,共 ${total} 步">
<span>${String(current).padStart(2, "0")}</span>
<span class="stepper__track" style="--progress:${progress}%"></span>
<span>${String(total).padStart(2, "0")}</span>
</div>`;
}
function welcomeView() {
return `
<section class="page page--welcome" aria-labelledby="welcome-title">
<div>
<p class="eyebrow">慢一点,看清当下</p>
<h1 id="welcome-title">把一个犹豫,安静地放在这里。</h1>
<p class="lede">用你手中的三枚硬币,依次记录六次结果。这里提供文化文本与反思线索,不替你预测,也不替你决定。</p>
<div class="hero-mark" aria-hidden="true">
<span class="hero-mark__dot">观</span>
</div>
<ul class="principles" aria-label="产品原则">
<li>手动记录</li>
<li>本地成卦</li>
<li>选择解读</li>
</ul>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="begin">
写下此刻所问 ${icons.arrow}
</button>
<p class="fine-print" style="margin:0;text-align:center">工作名“一问”仅用于本轮视觉评审</p>
</div>
</section>`;
}
function historyRecordData(record) {
const changedLines = transformLines(record.lines);
const movingIndexes = record.lines
.map((value, index) => lineIsMoving(value) ? index : -1)
.filter((index) => index >= 0);
return {
primary: lookupHexagram(record.lines),
changed: lookupHexagram(changedLines),
changedLines,
movingIndexes
};
}
function recordRoute(record) {
const data = historyRecordData(record);
return data.movingIndexes.length
? `${data.primary.name} <span>之</span> ${data.changed.name}`
: data.primary.name;
}
function recentRecordCard(record) {
const data = historyRecordData(record);
return `
<button class="recent-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
<span class="recent-card__top">
<span>${record.date}</span>
<span class="source-badge">最近一次</span>
</span>
<span class="recent-card__route">${recordRoute(record)}</span>
<span class="recent-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
<span class="recent-card__meta">
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
<span>${record.explanationSource ?? "仅保存卦象"}</span>
${icons.chevron}
</span>
</button>`;
}
function homeView() {
const latest = state.historyRecords[0];
return `
<section class="page page--home" aria-labelledby="home-title">
<header class="home-intro">
<p class="eyebrow">慢一点,看清当下</p>
<h1 id="home-title">此刻,有什么想安静看清?</h1>
<p class="lede">你投币,应用记录;结果用于整理想法,不替你预测或决定。</p>
<button class="button button--primary home-primary" type="button" data-action="begin">
开始一问 ${icons.arrow}
</button>
</header>
<section class="home-shelf" aria-labelledby="reflection-shelf-title">
<div class="section-heading home-shelf__heading">
<h2 id="reflection-shelf-title">你的观照</h2>
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
</div>
${latest ? recentRecordCard(latest) : `
<button class="home-empty-card" type="button" data-action="open-history">
<span class="home-empty-card__icon">${icons.book}</span>
<span><strong>问卦簿尚无记录</strong><small>${state.autoSaveHistory ? "完成起卦后会默认保存在本机" : "自动保存已关闭,可在结果页保存本次"}</small></span>
${icons.chevron}
</button>`}
<aside class="local-data-card" data-local-data-notice>
<span class="local-data-card__icon">${icons.lock}</span>
<div>
<strong>起卦与历史默认保存在本机,不主动上传。</strong>
<p>只有你选择 AI 解读时,本次所需内容才会发送。</p>
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
</div>
</aside>
</section>
</section>`;
}
function historyCard(record) {
const data = historyRecordData(record);
return `
<li>
<button class="history-card" type="button" data-action="open-history-detail" data-record-id="${record.id}">
<span class="history-card__top">
<span>${record.date}</span>
<span>${data.movingIndexes.length ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
</span>
<span class="history-card__route">${recordRoute(record)}</span>
<span class="history-card__question">${escapeHtml(record.question ?? "未保存问题原文")}</span>
<span class="history-card__footer">
<span>${record.explanationSource ?? "仅保存卦象"}</span>
${icons.chevron}
</span>
</button>
</li>`;
}
function historyView() {
const hasRecords = state.historyRecords.length > 0;
return `
<section class="page page--compact page--history" aria-labelledby="history-title">
<p class="eyebrow">本机记录</p>
<div class="history-title-row">
<h1 id="history-title">问卦簿</h1>
${hasRecords ? `<span class="source-badge">${state.historyRecords.length} 条</span>` : ""}
</div>
<p class="lede">回看当时看见了什么,而不是用旧结果替今天做决定。</p>
<div class="privacy-strip">
${icons.lock}
<span>记录只在本机,默认排除系统备份与设备迁移;你可随时删除。</span>
</div>
${hasRecords ? `
<ol class="history-list" data-history-list>
${state.historyRecords.map(historyCard).join("")}
</ol>` : `
<div class="history-empty" data-history-empty>
<span class="history-empty__mark" aria-hidden="true"><i></i><i></i></span>
<h2>还没有保存的记录</h2>
<p>${state.autoSaveHistory ? "完成一次起卦后,会默认把问题、卦象与后续解读保存在本机。" : "自动保存已经关闭;完成后仍可在结果页选择保存本次。"}</p>
<button class="text-link" type="button" data-action="open-settings">管理保存设置</button>
</div>`}
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="begin">
${hasRecords ? "开始新的一问" : "开始第一次记录"} ${icons.arrow}
</button>
</div>
</section>`;
}
function settingRow({ action, title, description, checked, disabled = false, detail = false }) {
return `
<label class="setting-row${detail ? " setting-row--detail" : ""}${disabled ? " setting-row--disabled" : ""}">
<span class="setting-row__copy"><strong>${title}</strong><small>${description}</small></span>
<input
class="switch-control"
type="checkbox"
role="switch"
data-action="${action}"
${checked ? "checked" : ""}
${disabled ? "disabled" : ""}
/>
</label>`;
}
function settingsView() {
const detailsDisabled = !state.autoSaveHistory;
return `
<section class="page page--compact page--settings" aria-labelledby="settings-title">
<p class="eyebrow">本机与隐私</p>
<h1 id="settings-title">保存设置</h1>
<p class="lede">默认留住完整的反思过程;你可以全局关闭,也可以只调整保存内容。</p>
<div class="settings-boundary">
${icons.shield}
<div>
<strong>只保存在 App 私有存储</strong>
<p>历史不会主动上传,并默认排除系统备份、设备迁移与云同步。</p>
</div>
</div>
<section class="settings-card" aria-label="历史保存选项">
${settingRow({
action: "auto-save-toggle",
title: "自动保存完整记录",
description: state.autoSaveHistory ? "已开启 · 完成起卦后立即保存到本机" : "已关闭 · 结果页仍可手动保存本次",
checked: state.autoSaveHistory
})}
<div class="settings-card__details${detailsDisabled ? " settings-card__details--disabled" : ""}">
${settingRow({
action: "save-question-setting",
title: "保存问题原文",
description: "与你写下的卦象背景一同回顾",
checked: state.saveQuestion,
disabled: detailsDisabled,
detail: true
})}
${settingRow({
action: "save-explanation-setting",
title: "保存解读全文",
description: "本地或 AI 解读会加入同一次记录",
checked: state.saveExplanation,
disabled: detailsDisabled,
detail: true
})}
${settingRow({
action: "save-action-setting",
title: "保存行动记录",
description: "保留“可以试的一小步”与完成状态",
checked: state.saveAction,
disabled: detailsDisabled,
detail: true
})}
</div>
</section>
<p class="settings-ai-note">这些开关只控制本机历史,不会允许或触发 AI。每次 AI 解读仍需要你主动选择并确认发送范围。</p>
<section class="settings-danger" aria-labelledby="history-management-title">
<div>
<h2 id="history-management-title">管理本机记录</h2>
<p>当前原型会话中有 ${state.historyRecords.length} 条记录。</p>
</div>
<button class="button button--danger-quiet" type="button" data-action="open-clear-all" ${state.historyRecords.length ? "" : "disabled"}>清空全部问卦记录</button>
</section>
</section>`;
}
function historyDetailView() {
const record = state.historyRecords.find((item) => item.id === state.selectedRecordId);
if (!record) return historyView();
const data = historyRecordData(record);
const hasMoving = data.movingIndexes.length > 0;
return `
<article class="page page--compact page--history-detail" aria-labelledby="history-detail-title">
<header class="history-detail-header">
<p class="eyebrow">${record.date}</p>
<h1 id="history-detail-title">${recordRoute(record)}</h1>
<p>保存的起卦快照</p>
</header>
<div class="result-hero history-result-hero">
<div class="result-kicker"><span>coin-v1 · 本地快照</span><span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span></div>
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
${hexagramFigure(record.lines, data.primary, true)}
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
</div>
</div>
<section class="content-section" aria-labelledby="saved-question-title">
<div class="section-heading"><h2 id="saved-question-title">当时所问</h2><span class="source-badge">${record.question ? "已保存" : "未保存"}</span></div>
<div class="content-card"><p>${escapeHtml(record.question ?? "这条记录没有保存问题原文。卦象结果和方法版本仍可独立复核。")}</p></div>
</section>
<section class="content-section" aria-labelledby="saved-content-title">
<div class="section-heading"><h2 id="saved-content-title">保存内容</h2></div>
<dl class="record-facts">
<div><dt>起卦结果</dt><dd>${data.primary.name}${hasMoving ? `之${data.changed.name}` : ""} · ${data.movingIndexes.length ? data.movingIndexes.map((index) => lineName(record.lines[index], index)).join("、") : "无动爻"}</dd></div>
<div><dt>解释来源</dt><dd>${record.explanationSource ?? "未保存解释"}</dd></div>
<div><dt>版本</dt><dd>coin-v1 · content-demo</dd></div>
</dl>
</section>
${record.action ? `
<section class="content-section" aria-labelledby="saved-action-title">
<div class="small-step"><div class="small-step__label" id="saved-action-title">当时记下的一小步</div><p>${escapeHtml(record.action)}</p></div>
</section>` : ""}
<div class="detail-danger-zone">
<button class="button button--danger-quiet" type="button" data-action="delete-record">删除这条记录</button>
<p>删除前会再次确认;原型刷新后恢复演示数据。</p>
</div>
</article>`;
}
function questionView() {
return `
<section class="page" aria-labelledby="question-title">
${stepper(1, 2)}
<p class="eyebrow">起念</p>
<h1 id="question-title">你想看清什么?</h1>
<p class="lede">写成一个开放的问题,比只问“会不会”更容易照见可行动的部分。</p>
<label class="field-label" for="question-input">此刻的问题</label>
<textarea
id="question-input"
class="question-input"
maxlength="120"
placeholder="例如:面对这次工作调整,我怎样做能更稳妥?"
data-question-input
>${escapeHtml(state.question)}</textarea>
<div class="field-meta">
<span>${state.autoSaveHistory ? "完成起卦后默认保存在本机" : "自动保存已关闭,本次可稍后手动保存"}</span>
<span data-char-count>${state.question.length}/120</span>
</div>
<div class="privacy-note">
${icons.lock}
<p>起卦计算在本地完成。只有你主动选择 AI 解读并再次确认后,所需内容才会被发送。</p>
</div>
<button class="text-link" type="button" data-action="skip-question">不写具体内容,直接开始</button>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="start-casting" ${state.question.trim() ? "" : "disabled"}>
开始记录六爻 ${icons.arrow}
</button>
</div>
</section>`;
}
function lineStack(lines) {
return [5, 4, 3, 2, 1, 0].map((index) => {
const value = lines[index];
if (!value) {
return '<div class="line-slot line-slot--empty" aria-label="尚未记录"></div>';
}
const kind = lineIsYang(value) ? "yang" : "yin";
const moving = lineIsMoving(value) ? " line-slot--moving" : "";
return `<div class="line-slot line-slot--${kind}${moving}" aria-label="${lineName(value, index)},数值 ${value}${lineIsMoving(value) ? ",动爻" : ""}">
${lineIsMoving(value) ? `<span class="line-slot__number">${value}</span>` : ""}
</div>`;
}).join("");
}
function coinButton(value, index) {
const face = value === 2 ? "字" : value === 3 ? "背" : "点选";
const score = value ? String(value) : "字 ↔ 背";
const valueAttr = value ? `data-value="${value}"` : "";
const next = value === null ? "字,2 分" : value === 2 ? "背,3 分" : "字,2 分";
return `
<div class="coin-wrap">
<button
class="coin"
type="button"
data-action="toggle-coin"
data-index="${index}"
${valueAttr}
aria-label="第 ${index + 1} 枚硬币,当前${value ? `${face},${score} 分` : "未记录"};点击切换为${next}"
>
<span class="coin__value">
<span class="coin__face">${face}</span>
<span class="coin__score">${score}</span>
</span>
</button>
<span>硬币 ${index + 1}</span>
</div>`;
}
function castingView() {
const round = state.lines.length + 1;
const selected = state.coins.every((value) => value !== null);
const sum = selected ? state.coins.reduce((total, value) => total + value, 0) : null;
const currentType = sum
? ({ 6: "老阴 · 动", 7: "少阳", 8: "少阴", 9: "老阳 · 动" })[sum]
: "";
return `
<section class="page page--compact" aria-labelledby="casting-title">
${stepper(2, 2)}
<div class="question-chip" title="${escapeHtml(state.question)}">${escapeHtml(state.question)}</div>
<div class="casting-layout">
<div class="line-progress-card">
<div class="line-progress-copy">
<strong>${state.lines.length} / 6</strong>
<p>从下往上记录</p>
${state.lines.length ? '<button class="text-link" type="button" data-action="undo-line">撤销上一爻</button>' : ""}
</div>
<div class="line-stack" aria-label="已记录的六爻">${lineStack(state.lines)}</div>
</div>
<div class="coin-prompt">
<h2 id="casting-title">第 ${round} 爻</h2>
<p>依照手中硬币,逐枚点选“字”或“背”</p>
</div>
<div class="coins" aria-label="三枚硬币录入">
${state.coins.map(coinButton).join("")}
</div>
<div class="round-sum" aria-live="polite">
${sum ? `本次合计 <strong>${sum}</strong> · ${currentType}` : "点按硬币可在“字 2”与“背 3”间切换"}
</div>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="confirm-line" ${selected ? "" : "disabled"}>
${round === 6 ? "记录并查看结果" : `确认第 ${round} 爻`} ${icons.arrow}
</button>
</div>
</section>`;
}
function hexLine(value, index, showMoving) {
const kind = lineIsYang(value) ? "yang" : "yin";
const moving = showMoving && lineIsMoving(value) ? " hex-line--moving" : "";
return `<div class="hex-line hex-line--${kind}${moving}" aria-label="${lineName(value, index)}${moving ? ",动爻" : ""}"><span></span><span></span></div>`;
}
function hexagramFigure(lines, hexagram, showMoving) {
const rendered = [5, 4, 3, 2, 1, 0]
.map((index) => hexLine(lines[index], index, showMoving))
.join("");
return `
<div class="hexagram-view">
<div class="hexagram-lines" role="img" aria-label="第 ${hexagram.number} 卦,${hexagram.name}卦">${rendered}</div>
<div class="hexagram-name">${hexagram.name}</div>
<div class="hexagram-number">第 ${hexagram.number} 卦</div>
</div>`;
}
function resultCopy(data) {
if (data.primary.number === 24) {
return {
classic: "复:亨。出入无疾,朋来无咎。反复其道,七日来复。利有攸往。",
plain: "“复”提醒人把注意力放回可返回、可重新开始之处。它不是替你判断成败,而是邀请你看见:此刻是否有一条更朴素、更合乎本心的路。",
moving: "初九:不远复,无祇悔,元吉。",
movingPlain: "偏离尚不远时就察觉并回转,代价通常较小。先修正一个最近的动作,比一次解决所有问题更可行。"
};
}
return {
classic: `${data.primary.name}卦 · 第 ${data.primary.number} 卦。本原型仅演示排盘与阅读层级,正式经典文本将从经校勘的本地内容包读取。`,
plain: "先观察卦象呈现的张力与变化,再回到你能核实的事实、感受和资源。不要把符号当成替你下结论的证据。",
moving: data.movingIndexes.length ? `${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join("、")}发生变化。` : "",
movingPlain: "变化的位置可作为反思入口:哪里正在松动,哪里仍需要保留余地?"
};
}
function errorCard() {
return `
<div class="error-card" role="alert">
<h3>AI 解读暂时没有返回</h3>
<p class="quiet">起卦结果仍完整保存在本页。你可以改看本地解读,或稍后重试。</p>
<div class="error-actions">
<button class="button button--secondary" type="button" data-action="choose-local">本地解读</button>
<button class="button button--danger-quiet" type="button" data-action="retry-ai">重试</button>
</div>
</div>`;
}
function currentSavePanel({ compact = false } = {}) {
if (state.saved) {
const explanationAttached = state.explanationGenerated && state.saveExplanation;
const savedContext = state.saveQuestion && hasSavableQuestion() ? "问题、卦象" : "卦象";
return `
<div class="current-save-panel${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
<div class="current-save-panel__status">
<span class="current-save-panel__icon">${icons.book}</span>
<span>
<strong>${explanationAttached ? "解读已加入本次记录" : state.currentSaveMode === "automatic" ? "已自动保存到本机" : "已保存到本机问卦簿"}</strong>
<small>${explanationAttached ? `与${savedContext}归在同一次观照中` : `${savedContext}快照已进入问卦簿`}</small>
</span>
</div>
<div class="current-save-panel__actions">
<button class="text-link" type="button" data-action="open-history">查看问卦簿</button>
<button class="text-link text-link--danger" type="button" data-action="remove-current-record">本次不保存</button>
</div>
</div>`;
}
return `
<div class="current-save-panel current-save-panel--off${compact ? " current-save-panel--compact" : ""}" data-current-save-status>
<div class="current-save-panel__status">
<span class="current-save-panel__icon">${icons.lock}</span>
<span>
<strong>本次未保存</strong>
<small>${state.currentSessionOptedOut ? "你已选择不保留这次记录" : "自动保存已关闭;不会写入问卦簿"}</small>
</span>
</div>
<button class="button button--secondary" type="button" data-action="save-current">保存本次</button>
</div>`;
}
function resultView() {
const data = resultData();
const copy = resultCopy(data);
const hasMoving = data.movingIndexes.length > 0;
const question = state.question || "未记录具体问题";
return `
<section class="page page--compact" aria-labelledby="result-title">
<div class="result-hero">
<div class="result-kicker">
<span>起卦结果 · 本地计算</span>
<span>${hasMoving ? `${data.movingIndexes.length} 个动爻` : "无动爻"}</span>
</div>
<div class="hexagram-pair${hasMoving ? "" : " hexagram-pair--single"}">
${hexagramFigure(data.sourceLines, data.primary, true)}
${hasMoving ? `<div class="transform-arrow" aria-label="变化为">之</div>${hexagramFigure(data.changedLines, data.changed, false)}` : ""}
</div>
${hasMoving ? "" : '<div class="static-indicator">卦象稳定 · 不显示之卦</div>'}
</div>
<div class="content-section">
<div class="section-heading">
<h2 id="result-title">你所问</h2>
<span class="source-badge">本次记录</span>
</div>
<p class="question-chip" style="white-space:normal">${escapeHtml(question)}</p>
</div>
${state.aiError ? errorCard() : ""}
<div class="content-section">
<div class="section-heading">
<h2>经典原文</h2>
<span class="source-badge">经典文本</span>
</div>
<blockquote class="classic-quote">${copy.classic}</blockquote>
</div>
<div class="content-section">
<div class="section-heading">
<h2>本地白话</h2>
<span class="source-badge">本地内容</span>
</div>
<div class="plain-copy"><p>${copy.plain}</p></div>
</div>
${hasMoving ? `
<div class="content-section">
<div class="section-heading"><h2>动爻</h2><span class="source-badge">变化处</span></div>
<div class="moving-card">
<div class="moving-card__label">${data.movingIndexes.map((index) => lineName(data.sourceLines[index], index)).join(" · ")}</div>
<h3>${copy.moving}</h3>
<p class="quiet">${copy.movingPlain}</p>
</div>
</div>` : ""}
<hr class="divider" />
<div class="result-secondary-actions">
${currentSavePanel()}
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
</div>
<div class="bottom-action">
<button class="button button--primary" type="button" data-action="open-interpretation">
解 · 选择一种解读方式 ${icons.arrow}
</button>
</div>
</section>`;
}
function loadingView() {
return `
<section class="page" aria-labelledby="loading-title">
<div class="loading-state">
<div class="loading-orbit" aria-hidden="true"></div>
<h1 id="loading-title" style="font-size:27px">正在整理反思线索</h1>
<p class="lede">结果已经生成。AI 只在此处协助组织文字,不参与起卦。</p>
</div>
</section>`;
}
function explanationView() {
const data = resultData();
const ai = state.explanationSource === "ai";
return `
<article class="page page--compact" aria-labelledby="explanation-title">
<header class="interpretation-header">
<p class="eyebrow">${ai ? "辅助反思" : "离线阅读"}</p>
<h1 id="explanation-title">从“回到起点”开始</h1>
<div class="interpretation-meta">
<span class="source-badge">${ai ? "AI 生成" : "本地解读"}</span>
<span>${data.primary.name}卦 · 第 ${data.primary.number} 卦</span>
</div>
</header>
<p class="interpretation-lead">眼前更重要的,也许不是立刻选定一条路,而是辨认哪一步能让你重新获得稳定与判断力。</p>
<section class="content-section" aria-labelledby="observation-title">
<div class="section-heading"><h2 id="observation-title">可以先观察</h2></div>
<ul class="reflection-list">
<li>你对“调整”的担心里,哪些是已经发生的事实,哪些是尚未验证的推测?</li>
<li>复卦的动处在最初一爻,适合把注意力放在能尽早修正的小偏差。</li>
<li>之卦为坤,提示行动时给现实条件、协作关系与节奏留出空间。</li>
</ul>
</section>
<section class="content-section" aria-labelledby="questions-title">
<div class="section-heading"><h2 id="questions-title">问问自己</h2></div>
<ol class="question-list">
<li>如果暂时不追求一次做对,我最想先确认的一个事实是什么?</li>
<li>哪一种选择更容易保留回转余地,而不是把自己锁死?</li>
<li>我可以向谁说明顾虑,并获得一条具体信息?</li>
</ol>
</section>
<section class="content-section" aria-labelledby="step-title">
<div class="small-step">
<div class="small-step__label" id="step-title">可以试的一小步</div>
<p>在今天结束前,写下“已知事实、尚待确认、我的底线”各一条,再约一次不超过 20 分钟的信息沟通。</p>
</div>
</section>
<p class="safety-note">这是一种文化反思与文字整理,不是预测、诊断或替代你的判断。涉及医疗、法律、财务或人身安全时,请寻求相应专业支持。</p>
<div class="bottom-action">
${currentSavePanel({ compact: true })}
<button class="button button--quiet" type="button" data-action="back-to-result">回到起卦结果</button>
</div>
</article>`;
}
function deleteRecordModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="delete-modal-title" aria-describedby="delete-modal-description" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="delete-modal-title">删除这条记录?</h2>
<p id="delete-modal-description">卦象、问题、已保存解读和行动记录会一并从本机删除。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice notice--danger">
${icons.shield}<p>原型使用演示数据;正式版删除不可恢复,因此必须在这里再次确认。</p>
</div>
<div class="modal-actions">
<button class="button button--danger" type="button" data-action="confirm-delete">确认删除</button>
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
</div>
</section>
</div>`;
}
function clearAllModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet modal-sheet--dialog" role="alertdialog" aria-modal="true" aria-labelledby="clear-modal-title" aria-describedby="clear-modal-description" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="clear-modal-title">清空全部记录?</h2>
<p id="clear-modal-description">当前 ${state.historyRecords.length} 条记录的卦象、问题、解读和行动内容都会从本机删除。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice notice--danger">
${icons.shield}<p>这项操作不可恢复,但不会改变你的自动保存设置。</p>
</div>
<div class="modal-actions">
<button class="button button--danger" type="button" data-action="confirm-clear-all">确认清空全部</button>
<button class="button button--secondary" type="button" data-action="close-modal">取消</button>
</div>
</section>
</div>`;
}
function interpretationModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="interpretation-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="interpretation-modal-title">选择解读方式</h2>
<p>起卦结果不会改变,只切换阅读材料的来源。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="option-list">
<button class="option-card" type="button" data-action="choose-local">
<span class="option-card__icon" aria-hidden="true">本</span>
<span><strong>先看本地解读</strong><small>离线可用,不发送任何内容</small></span>
${icons.chevron}
</button>
<button class="option-card" type="button" data-action="choose-ai">
<span class="option-card__icon" aria-hidden="true">解</span>
<span><strong>使用 AI 辅助解读</strong><small>确认发送范围后再开始</small></span>
${icons.chevron}
</button>
</div>
</section>
</div>`;
}
function consentModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="consent-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="consent-modal-title">发送前,请你确认</h2>
<p>AI 不参与起卦,只根据既有结果整理反思线索。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="notice">
${icons.shield}
<p>将发送以下内容;不会发送设备标识、历史记录或其他本地数据。</p>
</div>
<ul class="data-list">
<li>你本次写下的问题</li>
<li>本卦、之卦与动爻结果</li>
<li>用于约束语气和安全边界的提示</li>
</ul>
<label class="consent-check">
<input type="checkbox" data-action="consent-toggle" ${state.consent ? "checked" : ""} />
<span>我已了解发送范围,并同意仅为本次 AI 解读使用这些内容。</span>
</label>
<div class="modal-actions">
<button class="button button--primary" type="button" data-action="confirm-ai" ${state.consent ? "" : "disabled"}>同意并开始解读</button>
<button class="button button--quiet" type="button" data-action="choose-local">改看本地解读</button>
</div>
</section>
</div>`;
}
function methodModal() {
return `
<div class="modal-backdrop" data-action="dismiss-modal">
<section class="modal-sheet" role="dialog" aria-modal="true" aria-labelledby="method-modal-title" data-modal-sheet>
<div class="modal-handle" aria-hidden="true"></div>
<header class="modal-header">
<div>
<h2 id="method-modal-title">三枚硬币法</h2>
<p>原型采用明确、可复核的固定规则。</p>
</div>
<button class="icon-button" type="button" data-action="close-modal" aria-label="关闭"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<ol class="method-steps">
<li>准备三枚硬币,在现实中投掷一次。</li>
<li>在页面逐枚记录字面或背面,确认后形成一爻。</li>
<li>从下往上重复六次;程序只换算,不替你随机生成。</li>
</ol>
<div class="score-table" aria-label="硬币计分规则">
<div><strong>字 · 2</strong><span>字面记两分</span></div>
<div><strong>背 · 3</strong><span>背面记三分</span></div>
</div>
<p class="fine-print">合计 6 为老阴、7 为少阳、8 为少阴、9 为老阳;6 与 9 是动爻。</p>
<button class="button button--secondary" style="width:100%" type="button" data-action="close-modal">知道了</button>
</section>
</div>`;
}
function renderModal() {
if (state.modal === "interpretation") modalRoot.innerHTML = interpretationModal();
else if (state.modal === "consent") modalRoot.innerHTML = consentModal();
else if (state.modal === "method") modalRoot.innerHTML = methodModal();
else if (state.modal === "delete") modalRoot.innerHTML = deleteRecordModal();
else if (state.modal === "clear-all") modalRoot.innerHTML = clearAllModal();
else modalRoot.innerHTML = "";
document.body.style.overflow = state.modal ? "hidden" : "";
if (state.modal) {
window.setTimeout(() => {
modalRoot.querySelector("button, input")?.focus();
}, 20);
}
}
function render(options = {}) {
if (state.screen === "welcome") screen.innerHTML = welcomeView();
else if (state.screen === "home") screen.innerHTML = homeView();
else if (state.screen === "history") screen.innerHTML = historyView();
else if (state.screen === "history-detail") screen.innerHTML = historyDetailView();
else if (state.screen === "settings") screen.innerHTML = settingsView();
else if (state.screen === "question") screen.innerHTML = questionView();
else if (state.screen === "casting") screen.innerHTML = castingView();
else if (state.screen === "result") screen.innerHTML = resultView();
else if (state.screen === "loading") screen.innerHTML = loadingView();
else if (state.screen === "explanation") screen.innerHTML = explanationView();
backButton.hidden = state.screen === "welcome" || state.screen === "home";
renderModal();
if (!options.preserveFocus) {
screen.focus({ preventScroll: true });
window.scrollTo({ top: 0, behavior: "auto" });
}
}
function showToast(message) {
toastRegion.innerHTML = `<div class="toast">${escapeHtml(message)}</div>`;
window.setTimeout(() => {
toastRegion.innerHTML = "";
}, 1800);
}
function closeModal() {
state.modal = null;
state.consent = false;
renderModal();
}
function goBack() {
if (state.modal) {
closeModal();
return;
}
if (state.screen === "question") state.screen = state.questionOrigin;
else if (state.screen === "history") state.screen = "home";
else if (state.screen === "history-detail") state.screen = "history";
else if (state.screen === "settings") state.screen = state.settingsOrigin;
else if (state.screen === "casting") state.screen = "question";
else if (state.screen === "result") state.screen = "question";
else if (state.screen === "loading" || state.screen === "explanation") state.screen = "result";
render();
}
function beginAiExplanation() {
state.modal = null;
state.screen = "loading";
state.explanationSource = "ai";
render();
window.setTimeout(() => {
if (state.screen !== "loading") return;
state.explanationGenerated = true;
attachExplanationToCurrentSession();
state.screen = "explanation";
render();
}, 900);
}
document.addEventListener("input", (event) => {
if (!event.target.matches("[data-question-input]")) return;
state.question = event.target.value;
document.querySelector("[data-char-count]").textContent = `${state.question.length}/120`;
const startButton = document.querySelector("[data-action='start-casting']");
if (startButton) startButton.disabled = !state.question.trim();
});
document.addEventListener("change", (event) => {
if (event.target.matches("[data-action='consent-toggle']")) {
state.consent = event.target.checked;
const confirm = document.querySelector("[data-action='confirm-ai']");
if (confirm) confirm.disabled = !state.consent;
} else if (event.target.matches("[data-action='auto-save-toggle']")) {
state.autoSaveHistory = event.target.checked;
render({ preserveFocus: true });
document.querySelector("[data-action='auto-save-toggle']")?.focus();
} else if (event.target.matches("[data-action='save-question-setting']")) {
state.saveQuestion = event.target.checked;
} else if (event.target.matches("[data-action='save-explanation-setting']")) {
state.saveExplanation = event.target.checked;
} else if (event.target.matches("[data-action='save-action-setting']")) {
state.saveAction = event.target.checked;
}
});
document.addEventListener("click", (event) => {
const actionTarget = event.target.closest("[data-action]");
if (!actionTarget) return;
const action = actionTarget.dataset.action;
if (action === "dismiss-modal" && event.target.closest("[data-modal-sheet]")) return;
if (action === "back") goBack();
else if (action === "begin") {
state.questionOrigin = state.screen;
state.screen = "question";
render();
} else if (action === "open-history") {
state.screen = "history";
render();
} else if (action === "open-settings") {
state.settingsOrigin = state.screen === "settings" ? "home" : state.screen;
state.screen = "settings";
render();
} else if (action === "open-history-detail") {
state.selectedRecordId = actionTarget.dataset.recordId;
state.screen = "history-detail";
render();
} else if (action === "skip-question") {
prepareNewSession();
state.question = "未记录具体问题(仅作自我观察)";
state.lines = [];
state.coins = [null, null, null];
state.screen = "casting";
render();
} else if (action === "start-casting") {
if (!state.question.trim()) return;
prepareNewSession();
state.lines = [];
state.coins = [null, null, null];
state.screen = "casting";
render();
} else if (action === "toggle-coin") {
const index = Number(actionTarget.dataset.index);
const current = state.coins[index];
state.coins[index] = current === null || current === 3 ? 2 : 3;
render({ preserveFocus: true });
document.querySelector(`[data-action='toggle-coin'][data-index='${index}']`)?.focus();
} else if (action === "confirm-line") {
if (!state.coins.every((value) => value !== null)) return;
state.lines.push(state.coins.reduce((total, value) => total + value, 0));
state.coins = [null, null, null];
if (state.lines.length === 6) {
if (state.autoSaveHistory) persistCurrentSession("automatic");
state.screen = "result";
}
render();
} else if (action === "undo-line") {
state.lines.pop();
state.coins = [null, null, null];
render();
showToast("已撤销上一爻");
} else if (action === "open-interpretation") {
state.modal = "interpretation";
renderModal();
} else if (action === "choose-ai") {
state.modal = "consent";
state.consent = false;
renderModal();
} else if (action === "choose-local") {
state.modal = null;
state.explanationSource = "local";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
state.screen = "explanation";
render();
} else if (action === "confirm-ai") {
if (!state.consent) return;
beginAiExplanation();
} else if (action === "retry-ai") {
state.aiError = false;
state.modal = "consent";
state.consent = false;
renderModal();
} else if (action === "save-current") {
persistCurrentSession();
render();
showToast("本次记录已保存到本机");
} else if (action === "remove-current-record") {
if (!state.saved) return;
state.selectedRecordId = state.currentRecordId;
state.deleteReturnScreen = state.screen;
state.modal = "delete";
renderModal();
} else if (action === "delete-record") {
state.deleteReturnScreen = "history";
state.modal = "delete";
renderModal();
} else if (action === "confirm-delete") {
const deletedCurrentSession = state.selectedRecordId === state.currentRecordId;
state.historyRecords = state.historyRecords.filter((record) => record.id !== state.selectedRecordId);
state.selectedRecordId = null;
state.modal = null;
if (deletedCurrentSession) {
state.saved = false;
state.currentSessionOptedOut = true;
state.currentSaveMode = null;
}
state.screen = state.deleteReturnScreen;
render();
showToast("记录已从本机删除");
} else if (action === "open-clear-all") {
if (!state.historyRecords.length) return;
state.modal = "clear-all";
renderModal();
} else if (action === "confirm-clear-all") {
const includedCurrentSession = Boolean(currentSessionRecord());
state.historyRecords = [];
state.selectedRecordId = null;
state.modal = null;
if (includedCurrentSession) {
state.saved = false;
state.currentSessionOptedOut = true;
state.currentSaveMode = null;
}
render();
showToast("已清空全部本机记录");
} else if (action === "restart") {
state.question = "";
state.lines = [];
state.coins = [null, null, null];
prepareNewSession();
state.questionOrigin = state.historyRecords.length ? "home" : "welcome";
state.screen = "question";
render();
} else if (action === "back-to-result") {
state.screen = "result";
render();
} else if (action === "open-method") {
state.modal = "method";
renderModal();
} else if (action === "close-modal" || action === "dismiss-modal") {
closeModal();
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.modal) closeModal();
});
function seedPreview() {
const view = new URLSearchParams(window.location.search).get("view");
if (!view || view === "welcome") return;
if (view === "question") {
state.screen = "question";
state.question = SAMPLE_QUESTION;
return;
}
if (["home", "home-empty", "history", "history-empty", "history-detail", "delete", "settings", "settings-off", "clear-all"].includes(view)) {
state.historyRecords = view === "home-empty" || view === "history-empty" ? [] : sampleHistory();
state.screen = view.startsWith("home")
? "home"
: view === "history-detail" || view === "delete"
? "history-detail"
: view === "settings" || view === "settings-off" || view === "clear-all"
? "settings"
: "history";
state.selectedRecordId = state.historyRecords[0]?.id ?? null;
if (view === "settings-off") state.autoSaveHistory = false;
if (view === "delete") state.modal = "delete";
if (view === "clear-all") state.modal = "clear-all";
return;
}
state.question = SAMPLE_QUESTION;
if (view === "casting") {
state.screen = "casting";
state.lines = [9, 8];
state.coins = [2, 3, null];
return;
}
state.lines = view === "static" ? [7, 7, 7, 7, 7, 7] : [...SAMPLE_LINES];
state.screen = "result";
if (view === "result-not-saved") state.autoSaveHistory = false;
if (state.autoSaveHistory) persistCurrentSession("automatic");
if (view === "consent") state.modal = "consent";
else if (view === "explanation") {
state.screen = "explanation";
state.explanationSource = "ai";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
} else if (view === "local") {
state.screen = "explanation";
state.explanationSource = "local";
state.explanationGenerated = true;
attachExplanationToCurrentSession();
} else if (view === "error") {
state.aiError = true;
}
}
seedPreview();
render();
})();