/* Divination flow: * 1. user fills form → submit * 2. modal collapses, compass bursts into fast spin (decaying ~30s) * 3. AI request runs in parallel * 4. when both timer & AI done → modal expands with structured report * 5. report saveable as a vertical poster image (mobile share) */ (function () { const { useState, useEffect, useRef } = React; const CF_MODEL = "@cf/qwen/qwen1.5-14b-chat-awq"; const SPIN_MS = 10000; /* ── 会话 & 支付 ── */ const SESSION_KEY = 'fortune_session'; // { token, email, usesRemaining } const PAID_USES = 5; // 每次购买获得的次数 function getSession() { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null'); } catch { return null; } } function saveSession(s) { localStorage.setItem(SESSION_KEY, JSON.stringify(s)); } function clearSession() { localStorage.removeItem(SESSION_KEY); } // Common countries (ISO-ish list) — labels use current i18n via getLang const COUNTRIES = [ { code: "CN", zh: "中国", en: "China", es: "China", ja: "中国" }, { code: "HK", zh: "中国香港", en: "Hong Kong", es: "Hong Kong", ja: "香港" }, { code: "TW", zh: "中国台湾", en: "Taiwan", es: "Taiwán", ja: "台湾" }, { code: "US", zh: "美国", en: "United States", es: "Estados Unidos", ja: "アメリカ" }, { code: "GB", zh: "英国", en: "United Kingdom",es: "Reino Unido", ja: "イギリス" }, { code: "JP", zh: "日本", en: "Japan", es: "Japón", ja: "日本" }, { code: "KR", zh: "韩国", en: "South Korea", es: "Corea del Sur", ja: "韓国" }, { code: "SG", zh: "新加坡",en: "Singapore", es: "Singapur", ja: "シンガポール" }, { code: "MY", zh: "马来西亚",en:"Malaysia", es: "Malasia", ja: "マレーシア" }, { code: "TH", zh: "泰国", en: "Thailand", es: "Tailandia", ja: "タイ" }, { code: "VN", zh: "越南", en: "Vietnam", es: "Vietnam", ja: "ベトナム" }, { code: "IN", zh: "印度", en: "India", es: "India", ja: "インド" }, { code: "AU", zh: "澳大利亚",en:"Australia", es: "Australia", ja: "オーストラリア" }, { code: "NZ", zh: "新西兰",en: "New Zealand", es: "Nueva Zelanda", ja: "ニュージーランド" }, { code: "CA", zh: "加拿大",en: "Canada", es: "Canadá", ja: "カナダ" }, { code: "DE", zh: "德国", en: "Germany", es: "Alemania", ja: "ドイツ" }, { code: "FR", zh: "法国", en: "France", es: "Francia", ja: "フランス" }, { code: "IT", zh: "意大利",en: "Italy", es: "Italia", ja: "イタリア" }, { code: "ES", zh: "西班牙",en: "Spain", es: "España", ja: "スペイン" }, { code: "PT", zh: "葡萄牙",en: "Portugal", es: "Portugal", ja: "ポルトガル" }, { code: "NL", zh: "荷兰", en: "Netherlands", es: "Países Bajos", ja: "オランダ" }, { code: "SE", zh: "瑞典", en: "Sweden", es: "Suecia", ja: "スウェーデン" }, { code: "CH", zh: "瑞士", en: "Switzerland", es: "Suiza", ja: "スイス" }, { code: "RU", zh: "俄罗斯",en: "Russia", es: "Rusia", ja: "ロシア" }, { code: "BR", zh: "巴西", en: "Brazil", es: "Brasil", ja: "ブラジル" }, { code: "MX", zh: "墨西哥",en: "Mexico", es: "México", ja: "メキシコ" }, { code: "AR", zh: "阿根廷",en: "Argentina", es: "Argentina", ja: "アルゼンチン" }, { code: "AE", zh: "阿联酋",en: "UAE", es: "EAU", ja: "UAE" }, { code: "SA", zh: "沙特", en: "Saudi Arabia", es: "Arabia Saudí", ja: "サウジアラビア" }, { code: "ZA", zh: "南非", en: "South Africa", es: "Sudáfrica", ja: "南アフリカ" }, { code: "OTHER", zh: "其他",en: "Other", es: "Otro", ja: "その他" }, ]; const COMPASS_CONTEXT = `本罗盘共六层: 1) 太极 阴阳 2) 八卦:乾(天) 兑(泽) 离(火) 震(雷) 巽(风) 坎(水) 艮(山) 坤(地) 3) 十二地支:子丑寅卯辰巳午未申酉戌亥 4) 二十四节气:立春雨水惊蛰春分清明谷雨立夏小满芒种夏至小暑大暑立秋处暑白露秋分寒露霜降立冬小雪大雪冬至小寒大寒 5) 二十四山:壬子癸丑艮寅甲卯乙辰巽巳丙午丁未坤申庚酉辛戌乾亥 6) 六十四卦:乾坤屯蒙需讼师比小畜履泰否同人大有谦豫随蛊临观噬嗑贲剥复无妄大畜颐大过坎离咸恒遁大壮晋明夷家人睽蹇解损益夬姤萃升困井革鼎震艮渐归妹丰旅巽兑涣节中孚小过既济未济`; const ZODIAC = ["鼠","牛","虎","兔","龙","蛇","马","羊","猴","鸡","狗","猪"]; const STEMS = ["甲","乙","丙","丁","戊","己","庚","辛","壬","癸"]; const BRANCH = ["子","丑","寅","卯","辰","巳","午","未","申","酉","戌","亥"]; const t = (k) => (window.I18N ? window.I18N.t(k) : k); const getLang = () => (window.I18N ? window.I18N.getLang() : 'zh'); function useLangBump() { const [, f] = React.useReducer(x => x + 1, 0); React.useEffect(() => { const h = () => f(); window.addEventListener('langchange', h); return () => window.removeEventListener('langchange', h); }, []); } function calcInfo(birthday) { if (!birthday) return null; const d = new Date(birthday); if (isNaN(d)) return null; const y = d.getFullYear(); const stemIdx = ((y - 1984) % 10 + 10) % 10; const branchIdx = ((y - 1984) % 12 + 12) % 12; return { year: y, zodiac: ZODIAC[branchIdx], ganzhi: STEMS[stemIdx] + BRANCH[branchIdx], month: d.getMonth() + 1, day: d.getDate(), }; } function buildPrompt(form) { const info = calcInfo(form.birthday); const meta = info ? `生年:${info.year}(${info.ganzhi}年,属${info.zodiac}),公历:${info.year}-${String(info.month).padStart(2,"0")}-${String(info.day).padStart(2,"0")}` : `生日:${form.birthday || "未填写"}`; const lang = getLang(); const titles = (window.I18N ? window.I18N.t('sec_titles') : null) || ['1','2','3','4','5','6','7']; const langInstr = { zh: '用简洁的现代白话文写作,不用文言文,普通人能轻松读懂。语气温和、亲切,像朋友聊天一样自然。', en: 'Write in clear, warm, everyday English. No markdown. Speak like a thoughtful friend.', es: 'Escribe en español claro y cotidiano. Sin markdown. Como un amigo reflexivo.', ja: '分かりやすい現代語で、親しみやすい口調で書いてください。マークダウンは使わない。', }[lang] || 'Write in clear, warm, everyday English. No markdown.'; return `You are a thoughtful guide framing the Chinese I-Ching as a tool for self-reflection, mindfulness and cultural appreciation — NOT as fortune-telling, prediction, or advice. Avoid medical, legal, financial, or definite future claims. Speak in metaphor; invite the reader to reflect. Reference compass (cultural symbols only): ${COMPASS_CONTEXT} Reader: Country: ${(COUNTRIES.find(c => c.code === form.country) || {}).en || form.country || "-"} Name: ${form.name || "-"} Gender: ${form.gender} ${meta} ${langInstr} For each of the seven sections below, write 2–3 short reflective sentences, addressing the reader by their name where natural. Use this EXACT format with bracketed titles, in the requested language: 【${titles[0]}】 ( a gentle observation about the reader's present state, framed as imagery ) 【${titles[1]}】 ( pick ONE hexagram from the 64 as a metaphor for self-reflection, name it, and give its image meaning — not a prediction ) 【${titles[2]}】 ( the reader's elemental tendency among Wood/Fire/Earth/Metal/Water as a personality lens ) 【${titles[3]}】 ( one of the 24 solar terms, used as a seasonal mood metaphor for inner life ) 【${titles[4]}】 ( a direction from the eight trigrams, framed as a symbolic orientation for attention — not a place to go ) 【${titles[5]}】 ( a short mindfulness invitation: a breath, a question, a thing to notice this week ) 【${titles[6]}】 ( two or three gentle, non-prescriptive practices: journaling prompt, breathing, observation — never medical ) End by softly reminding the reader this is a cultural reflection, not a prediction. Output the seven bracketed sections only.`; } function parseReport(text) { if (!text) return []; const out = []; const re = /【([^】]+)】\s*([\s\S]*?)(?=【|$)/g; let m; while ((m = re.exec(text)) !== null) { out.push({ title: m[1].trim(), body: m[2].trim() }); } if (out.length === 0) { // fallback: split by 一二三... const lines = text.split(/\n+/).filter(Boolean); return [{ title: t('report_title'), body: lines.join("\n") }]; } return out; } function getCfg(){ try{return JSON.parse(localStorage.getItem("cf_cfg")||"{}");}catch{return{};} } function setCfg(c){ localStorage.setItem("cf_cfg", JSON.stringify(c)); } const CF_MESSAGES_WRAPPER = (prompt) => ({ messages: [ { role: "system", content: "You are a thoughtful cultural guide who frames the I-Ching as a mindfulness and self-reflection tool, never as fortune-telling. You avoid medical, legal, financial, or predictive claims and invite gentle reflection." }, { role: "user", content: prompt }, ], max_tokens: 1500, }); function extractResult(data) { if (!data.success) throw new Error((data.errors || []).map(e => e.message).join("; ") || "AI 请求失败"); const r = data.result; // OpenAI-compatible format(新模型:choices[0].message.content) const text = r?.choices?.[0]?.message?.content ?? r?.response ?? r?.output ?? (typeof r === "string" ? r : JSON.stringify(r)); // 把模型输出的字面量 \n(反斜杠+n)转成真正的换行符 return String(text).replace(/\\n/g, "\n").trim(); } /** 优先:通过 Pages Function 代理(需带 session token) * 返回 { text, usesRemaining } */ async function callProxy(prompt, sessionToken) { const headers = { "Content-Type": "application/json" }; if (sessionToken) headers["Authorization"] = `Bearer ${sessionToken}`; const resp = await fetch("/api/ai", { method: "POST", headers, body: JSON.stringify(CF_MESSAGES_WRAPPER(prompt)), }); const data = await resp.json(); // Auth / usage 错误码直接上抛 if (data.code === 'NOT_LOGGED_IN') throw new Error('NOT_LOGGED_IN'); if (data.code === 'SESSION_EXPIRED') throw new Error('SESSION_EXPIRED'); if (data.code === 'NO_USES') throw new Error('NO_USES'); return { text: extractResult(data), usesRemaining: data.usesRemaining }; } /** 降级:用户在 ⚙ 中手动填写的凭证直连 Cloudflare API */ async function callCloudflare(prompt, cfg) { const url = `https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/ai/run/${cfg.model || CF_MODEL}`; const resp = await fetch(url, { method: "POST", headers: { "Authorization": `Bearer ${cfg.token}`, "Content-Type": "application/json" }, body: JSON.stringify(CF_MESSAGES_WRAPPER(prompt)), }); if (!resp.ok) { const t = await resp.text(); throw new Error(`Cloudflare API ${resp.status}: ${t.slice(0,200)}`); } const data = await resp.json(); return { text: extractResult(data) }; } /** 最后降级:Claude Code 内置预览环境 */ async function callClaude(prompt) { if (!window.claude || typeof window.claude.complete !== "function") { throw new Error("未检测到 AI 接口,请先登录后使用。"); } return { text: await window.claude.complete(prompt) }; } /** 统一入口:代理 → 客户端配置 → window.claude * 返回 { text, usesRemaining? } */ async function callAI(prompt, provider, cfg, sessionToken) { if (provider !== "claude") { try { return await callProxy(prompt, sessionToken); } catch (e) { if (['NOT_LOGGED_IN','SESSION_EXPIRED','NO_USES'].includes(e.message)) throw e; if (!e.message.includes("未配置") && !String(e).includes("503") && !String(e).includes("fetch")) throw e; } } if ((provider === "cloudflare" || provider === "auto") && cfg.accountId && cfg.token) { return await callCloudflare(prompt, cfg); } return await callClaude(prompt); } /* ========== Poster generator ========== */ function wrapText(ctx, text, x, y, maxW, lineHeight) { const chars = [...text]; let line = ""; let curY = y; for (let i = 0; i < chars.length; i++) { const test = line + chars[i]; if (ctx.measureText(test).width > maxW && line) { ctx.fillText(line, x, curY); line = chars[i]; curY += lineHeight; } else { line = test; } } if (line) { ctx.fillText(line, x, curY); curY += lineHeight; } return curY; } function generatePoster(form, sections) { const W = 750; const PAD_L = 70; const MAX_TW = W - 140; const BODY_LH = 36; // line height for body text const SITE_URL = "fate.fw39.com"; const DISCLAIMER = "本报告为易经文化意象参考,非命理预测或专业建议,仅供自我反思之用。"; // ── Pass 1: measure actual content height ────────────────────────────── // Use a temporary canvas so measureText works with correct font metrics const tmp = document.createElement("canvas"); tmp.width = W; const mCtx = tmp.getContext("2d"); function measureWrappedLines(ctx, text, maxW, font) { ctx.font = font; const chars = [...text]; let line = "", lines = 0; for (const ch of chars) { const test = line + ch; if (ctx.measureText(test).width > maxW && line) { lines++; line = ch; } else { line = test; } } if (line) lines++; return lines; } // Fixed areas above sections // header title+sub: 0→140 // bagua circle: 140→385 (cy=260, R=100 + gap) // user info card: 385→540 let estimatedH = 540; sections.forEach(s => { estimatedH += 64; // section title bar + padding // body: split by real newlines, wrap each paragraph const paras = s.body.split("\n").filter(p => p.trim()); paras.forEach(p => { const lines = measureWrappedLines(mCtx, p.trim(), MAX_TW, `17px 'Noto Serif SC', serif`); estimatedH += lines * BODY_LH; }); estimatedH += 32; // gap between sections }); estimatedH += 10 + 90; // stamp (80) + gap estimatedH += 30 + 22 + 22 + 22; // disclaimer + url + date + poem estimatedH += 60; // bottom border padding const H = Math.max(estimatedH, 900); // ── Pass 2: draw ─────────────────────────────────────────────────────── const c = document.createElement("canvas"); c.width = W; c.height = H; const ctx = c.getContext("2d"); // Background gradient const g = ctx.createLinearGradient(0, 0, 0, H); g.addColorStop(0, "#1a0a26"); g.addColorStop(0.4, "#0a0418"); g.addColorStop(1, "#1a0810"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); // Nebula glows const grad1 = ctx.createRadialGradient(W*0.2, H*0.15, 0, W*0.2, H*0.15, 400); grad1.addColorStop(0, "rgba(140,70,200,0.35)"); grad1.addColorStop(1, "rgba(140,70,200,0)"); ctx.fillStyle = grad1; ctx.fillRect(0, 0, W, H); const grad2 = ctx.createRadialGradient(W*0.85, H*0.6, 0, W*0.85, H*0.6, 500); grad2.addColorStop(0, "rgba(40,130,200,0.25)"); grad2.addColorStop(1, "rgba(40,130,200,0)"); ctx.fillStyle = grad2; ctx.fillRect(0, 0, W, H); // Stars (deterministic seed via section count so same poster = same stars) let rng = sections.length * 1234567; const rand = () => { rng = (rng * 1664525 + 1013904223) & 0xffffffff; return (rng >>> 0) / 0xffffffff; }; for (let i = 0; i < 80; i++) { const sx = rand() * W, sy = rand() * H, sr = rand() * 1.4 + 0.3; ctx.globalAlpha = 0.3 + rand() * 0.6; ctx.fillStyle = "#fff"; ctx.beginPath(); ctx.arc(sx, sy, sr, 0, Math.PI*2); ctx.fill(); } ctx.globalAlpha = 1; // Border ctx.strokeStyle = "rgba(255,206,92,0.5)"; ctx.lineWidth = 1.5; ctx.strokeRect(20, 20, W-40, H-40); ctx.strokeStyle = "rgba(255,206,92,0.25)"; ctx.lineWidth = 1; ctx.strokeRect(28, 28, W-56, H-56); // Title ctx.textAlign = "center"; ctx.fillStyle = "#ffe9a8"; ctx.font = "700 50px 'Ma Shan Zheng','STKaiti',serif"; ctx.shadowColor = "rgba(255,206,92,0.7)"; ctx.shadowBlur = 24; ctx.fillText("玄 机 罗 盘", W/2, 100); ctx.shadowBlur = 0; ctx.font = "400 16px 'Noto Serif SC',serif"; ctx.fillStyle = "rgba(255,233,168,0.6)"; ctx.fillText("心 象 报 告", W/2, 130); // Bagua circle const bx = W/2, by = 260, R = 100; ctx.strokeStyle = "rgba(255,206,92,0.7)"; ctx.lineWidth = 1.2; [R+10, R, R-25, R-50, R-75].forEach(rr => { ctx.beginPath(); ctx.arc(bx, by, rr, 0, Math.PI*2); ctx.stroke(); }); const trigs = ["☰","☱","☲","☳","☴","☵","☶","☷"]; ctx.fillStyle = "#ffce5c"; ctx.font = "20px serif"; trigs.forEach((tg, i) => { const a = (i/8)*Math.PI*2 - Math.PI/2; ctx.fillText(tg, bx+(R-12)*Math.cos(a), by+(R-12)*Math.sin(a)+7); }); ctx.fillStyle = "#ffe9a8"; ctx.beginPath(); ctx.arc(bx, by, 22, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = "#1a0810"; ctx.beginPath(); ctx.arc(bx, by, 22, -Math.PI/2, Math.PI/2); ctx.arc(bx, by+11, 11, Math.PI/2, -Math.PI/2, true); ctx.arc(bx, by-11, 11, Math.PI/2, -Math.PI/2); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#1a0810"; ctx.beginPath(); ctx.arc(bx, by-11, 3, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = "#ffe9a8"; ctx.beginPath(); ctx.arc(bx, by+11, 3, 0, Math.PI*2); ctx.fill(); // User info card let y = 385; ctx.fillStyle = "rgba(255,206,92,0.08)"; ctx.fillRect(50, y, W-100, 115); ctx.strokeStyle = "rgba(255,206,92,0.3)"; ctx.lineWidth = 1; ctx.strokeRect(50, y, W-100, 115); y += 38; ctx.textAlign = "left"; ctx.fillStyle = "#ffe9a8"; ctx.font = "600 26px 'Ma Shan Zheng','STKaiti',serif"; ctx.fillText(form.name || "无名氏", PAD_L, y); const info = calcInfo(form.birthday); ctx.font = "14px 'Noto Serif SC',serif"; ctx.fillStyle = "rgba(255,233,168,0.7)"; ctx.fillText(`${form.country||""} · ${form.gender==="M"?"男":"女"} · ${form.birthday||""}`, PAD_L, y+28); if (info) { ctx.fillStyle = "#ffce5c"; ctx.font = "16px 'Ma Shan Zheng',serif"; ctx.fillText(`${info.ganzhi}年 · 属 ${info.zodiac}`, PAD_L, y+56); } y = 385 + 115 + 20; // Sections sections.forEach(s => { // Left accent bar + title (title already contains ordinal, e.g. "一 · 处境之象") ctx.fillStyle = "rgba(255,206,92,0.9)"; ctx.fillRect(50, y + 2, 4, 26); ctx.fillStyle = "#ffce5c"; ctx.font = "600 20px 'Ma Shan Zheng','STKaiti',serif"; ctx.textAlign = "left"; ctx.shadowColor = "rgba(255,206,92,0.4)"; ctx.shadowBlur = 8; ctx.fillText(s.title, PAD_L, y + 26); ctx.shadowBlur = 0; // section title underline ctx.strokeStyle = "rgba(255,206,92,0.2)"; ctx.lineWidth = 0.8; ctx.beginPath(); ctx.moveTo(50, y + 38); ctx.lineTo(W - 50, y + 38); ctx.stroke(); y += 54; // Body — preserve paragraphs (split by \n) ctx.fillStyle = "rgba(255,233,168,0.88)"; ctx.font = "17px 'Noto Serif SC',serif"; const paras = s.body.split("\n").filter(p => p.trim()); paras.forEach(p => { y = wrapText(ctx, p.trim(), PAD_L, y, MAX_TW, BODY_LH); }); y += 30; // gap between sections }); // Stamp y += 14; ctx.fillStyle = "rgba(165,42,20,0.9)"; ctx.fillRect(W/2-38, y, 76, 76); ctx.strokeStyle = "rgba(255,206,92,0.6)"; ctx.lineWidth = 1.5; ctx.strokeRect(W/2-38, y, 76, 76); ctx.fillStyle = "#fff5d6"; ctx.font = "600 36px 'Ma Shan Zheng',serif"; ctx.textAlign = "center"; ctx.fillText("易", W/2, y + 52); y += 96; // Divider ctx.strokeStyle = "rgba(255,206,92,0.3)"; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(60, y); ctx.lineTo(W-60, y); ctx.stroke(); y += 24; // Disclaimer ctx.fillStyle = "rgba(255,233,168,0.45)"; ctx.font = "12px 'Noto Serif SC',serif"; ctx.textAlign = "center"; ctx.fillText(DISCLAIMER, W/2, y); y += 22; // Website URL ctx.fillStyle = "rgba(255,206,92,0.7)"; ctx.font = "13px 'Noto Serif SC',serif"; ctx.fillText(SITE_URL, W/2, y); y += 22; // Date ctx.fillStyle = "rgba(255,233,168,0.4)"; ctx.font = "12px 'Noto Serif SC',serif"; ctx.fillText(new Date().toLocaleDateString("zh-CN", { year:"numeric", month:"long", day:"numeric" }), W/2, y); return c; } async function savePoster(canvas, name) { const blob = await new Promise(res => canvas.toBlob(res, "image/png")); const filename = `${name || "reflection"}_iching.png`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } /* ========== DateField — Y/M/D dropdowns ========== */ function DateField({ label, value, onChange, optional }) { // Parse a "YYYY-MM-DD" string into [year, month, day] strings whose values // match the