feat: add mobile interaction prototype
This commit is contained in:
Vendored
+733
@@ -0,0 +1,733 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const SAMPLE_QUESTION = "我是否要接受这次工作调整?怎样做能更稳妥?";
|
||||
const SAMPLE_LINES = [9, 8, 8, 8, 8, 8];
|
||||
|
||||
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>'
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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 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 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>只保存在本次原型会话中</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 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" />
|
||||
<button class="text-link" type="button" data-action="restart">重新起一卦</button>
|
||||
|
||||
<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">
|
||||
<button class="button button--secondary" type="button" data-action="back-to-result">回到起卦结果</button>
|
||||
</div>
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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 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 === "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";
|
||||
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 = "welcome";
|
||||
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.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']")) return;
|
||||
state.consent = event.target.checked;
|
||||
const confirm = document.querySelector("[data-action='confirm-ai']");
|
||||
if (confirm) confirm.disabled = !state.consent;
|
||||
});
|
||||
|
||||
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.screen = "question";
|
||||
render();
|
||||
} else if (action === "skip-question") {
|
||||
state.question = "未记录具体问题(仅作自我观察)";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.screen = "casting";
|
||||
render();
|
||||
} else if (action === "start-casting") {
|
||||
if (!state.question.trim()) return;
|
||||
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) {
|
||||
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.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 === "restart") {
|
||||
state.question = "";
|
||||
state.lines = [];
|
||||
state.coins = [null, null, null];
|
||||
state.aiError = false;
|
||||
state.screen = "welcome";
|
||||
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;
|
||||
}
|
||||
|
||||
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 === "consent") state.modal = "consent";
|
||||
else if (view === "explanation") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "ai";
|
||||
} else if (view === "local") {
|
||||
state.screen = "explanation";
|
||||
state.explanationSource = "local";
|
||||
} else if (view === "error") {
|
||||
state.aiError = true;
|
||||
}
|
||||
}
|
||||
|
||||
seedPreview();
|
||||
render();
|
||||
})();
|
||||
Reference in New Issue
Block a user