/* 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 = 15000; // 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, contemplative English. No markdown.', es: 'Escribe en español claro y contemplativo. Sin markdown.', ja: '簡潔で詩的な日本語で書いてください。マークダウンは使わない。', }[lang] || 'Write in clear, contemplative English.'; 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 请求失败"); return data.result.response || data.result.output || JSON.stringify(data.result); } /** 优先:通过 Pages Function 代理(Token 留在服务端) */ async function callProxy(prompt) { const resp = await fetch("/api/ai", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(CF_MESSAGES_WRAPPER(prompt)), }); const data = await resp.json(); return extractResult(data); } /** 降级:用户在 ⚙ 中手动填写的凭证直连 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 extractResult(data); } /** 最后降级:Claude Code 内置预览环境 */ async function callClaude(prompt) { if (!window.claude || typeof window.claude.complete !== "function") { throw new Error("未检测到 AI 接口。请在 Cloudflare Pages → Settings → Environment variables 中配置 CF_AI_TOKEN,或在配置(⚙)中手动填入凭证。"); } return await window.claude.complete(prompt); } /** 统一入口:代理 → 客户端配置 → window.claude */ async function callAI(prompt, provider, cfg) { // 1. 服务端代理(已部署时优先使用) if (provider !== "claude") { try { return await callProxy(prompt); } catch (e) { // 若是 503(未配置凭证)或网络错误则继续降级;其他错误直接抛出 if (!e.message.includes("未配置") && !String(e).includes("503") && !String(e).includes("fetch")) throw e; } } // 2. 用户手动配置的 Cloudflare 凭证 if ((provider === "cloudflare" || provider === "auto") && cfg.accountId && cfg.token) { return await callCloudflare(prompt, cfg); } // 3. Claude Code 预览环境 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; // Estimate height let H = 1100; sections.forEach(s => { H += 80 + Math.ceil(s.body.length / 18) * 36; }); H = Math.max(H, 1334); 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); // Subtle 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 ctx.fillStyle = "#fff"; for (let i = 0; i < 80; i++) { const x = Math.random() * W, y = Math.random() * H; const r = Math.random() * 1.4 + 0.3; ctx.globalAlpha = 0.3 + Math.random() * 0.6; ctx.beginPath(); ctx.arc(x, y, r, 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.strokeRect(28, 28, W - 56, H - 56); // Title ctx.fillStyle = "#ffe9a8"; ctx.font = "700 50px 'Ma Shan Zheng', 'STKaiti', serif"; ctx.textAlign = "center"; 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); // Mini bagua circle const cx = W/2, cy = 260; const 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(cx, cy, rr, 0, Math.PI*2); ctx.stroke(); }); // Trigrams around const trigs = ["☰","☱","☲","☳","☴","☵","☶","☷"]; ctx.fillStyle = "#ffce5c"; ctx.font = "20px serif"; trigs.forEach((t, i) => { const a = (i / 8) * Math.PI * 2 - Math.PI/2; ctx.fillText(t, cx + (R - 12) * Math.cos(a), cy + (R - 12) * Math.sin(a) + 7); }); // Yin-yang center ctx.fillStyle = "#ffe9a8"; ctx.beginPath(); ctx.arc(cx, cy, 22, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = "#1a0810"; ctx.beginPath(); ctx.arc(cx, cy, 22, -Math.PI/2, Math.PI/2); ctx.arc(cx, cy + 11, 11, Math.PI/2, -Math.PI/2, true); ctx.arc(cx, cy - 11, 11, Math.PI/2, -Math.PI/2); ctx.closePath(); ctx.fill(); ctx.fillStyle = "#1a0810"; ctx.beginPath(); ctx.arc(cx, cy - 11, 3, 0, Math.PI*2); ctx.fill(); ctx.fillStyle = "#ffe9a8"; ctx.beginPath(); ctx.arc(cx, cy + 11, 3, 0, Math.PI*2); ctx.fill(); // User info card let y = 410; ctx.fillStyle = "rgba(255,206,92,0.08)"; ctx.fillRect(50, y - 30, W - 100, 110); ctx.strokeStyle = "rgba(255,206,92,0.3)"; ctx.strokeRect(50, y - 30, W - 100, 110); ctx.textAlign = "left"; ctx.fillStyle = "#ffe9a8"; ctx.font = "600 26px 'Ma Shan Zheng', 'STKaiti', serif"; ctx.fillText(form.name || "无名氏", 70, y); const info = calcInfo(form.birthday); ctx.font = "14px 'Noto Serif SC', serif"; ctx.fillStyle = "rgba(255,233,168,0.7)"; const infoLine = `${form.country || "中国"} · ${form.gender} · ${form.birthday || ""}`; ctx.fillText(infoLine, 70, y + 28); if (info) { ctx.fillStyle = "#ffce5c"; ctx.font = "16px 'Ma Shan Zheng', serif"; ctx.fillText(`${info.ganzhi}年 · 属 ${info.zodiac}`, 70, y + 56); } y += 130; // Sections sections.forEach((s, i) => { // Title bar ctx.fillStyle = "rgba(255,206,92,0.12)"; ctx.fillRect(50, y, 5, 32); ctx.fillStyle = "#ffce5c"; ctx.font = "600 22px 'Ma Shan Zheng', 'STKaiti', serif"; ctx.textAlign = "left"; ctx.shadowColor = "rgba(255,206,92,0.4)"; ctx.shadowBlur = 8; ctx.fillText(`${["一","二","三","四","五","六","七"][i] || (i+1)}、${s.title}`, 65, y + 24); ctx.shadowBlur = 0; y += 50; // Body ctx.fillStyle = "rgba(255,233,168,0.92)"; ctx.font = "16px 'Noto Serif SC', serif"; const text = s.body.replace(/\s+/g, ""); y = wrapText(ctx, text, 70, y + 4, W - 140, 30); y += 18; }); y += 10; // Stamp ctx.fillStyle = "#a52a14"; ctx.fillRect(W/2 - 40, y, 80, 80); ctx.fillStyle = "#fff5d6"; ctx.font = "600 38px 'Ma Shan Zheng', serif"; ctx.textAlign = "center"; ctx.fillText("易", W/2, y + 56); y += 110; // Footer ctx.fillStyle = "rgba(255,233,168,0.5)"; ctx.font = "13px 'Noto Serif SC', serif"; ctx.textAlign = "center"; ctx.fillText("天   地   玄   黄   ·   宇   宙   洪   荒", W/2, y); ctx.fillText(new Date().toLocaleString("zh-CN"), W/2, y + 24); return c; } async function savePoster(canvas, name) { const blob = await new Promise(res => canvas.toBlob(res, "image/png")); const filename = `${name || "reflection"}_iching.png`; if (navigator.share && navigator.canShare && navigator.canShare({ files: [new File([blob], filename, { type: "image/png" })] })) { try { await navigator.share({ title: t('report_title'), files: [new File([blob], filename, { type: "image/png" })], }); return; } catch (e) { /* fall through to download */ } } 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