diff --git a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/check.jsonl b/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/check.jsonl
deleted file mode 100644
index 82bb4d1..0000000
--- a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/check.jsonl
+++ /dev/null
@@ -1,2 +0,0 @@
-{"file": ".trellis/spec/web/index.md", "reason": "Verify no hardcoded strings (i18n), correct Tailwind tokens, no RSC usage"}
-{"file": ".trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md", "reason": "Acceptance criteria: pixel match, SEO >= 95, npm audit clean"}
diff --git a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/implement.jsonl b/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/implement.jsonl
deleted file mode 100644
index a2833fc..0000000
--- a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/implement.jsonl
+++ /dev/null
@@ -1,3 +0,0 @@
-{"file": ".trellis/spec/web/index.md", "reason": "Web tech stack, design tokens, i18n rules, quality constraints"}
-{"file": ".trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md", "reason": "Task PRD with design analysis, architecture, acceptance criteria"}
-{"file": "web/design/eryao.pen", "reason": "Pencil design file - Landing Page layout, colors, typography, spacing"}
diff --git a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md b/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md
deleted file mode 100644
index 117073c..0000000
--- a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# Web 端技术选型 PRD
-
-## 背景
-
-eryao 项目需要建设 Web 端。移动端已有 Flutter 应用(iOS/Android),后端为 Python FastAPI + Supabase。Pencil 设计稿已完成(`web/design/eryao.pen`),包含 19 个页面。
-
-## 设计稿分析
-
-### 页面分类
-
-**营销/公开页面**(需要 SEO):
-- Landing Page — Hero + Showcase + Testimonials + CTA + Footer
-- Features Page — 功能特性展示
-- Pricing Page — 定价方案卡片
-- About Page — 关于我们 + 法律声明
-- Privacy Policy Page
-- Terms of Service Page
-
-**Dashboard/应用页面**(Auth 后 SPA):
-- Login Page — 登录表单卡片
-- Dashboard — 侧边栏(260px) + 主区域(1180px) 布局
-- NotificationPage — 通知列表
-- 其余 9 个页面(待展开)
-
-### 设计特征
-- 宽度 1440px,桌面端为主
-- 色系:紫色主色(#7C3AED)、深蓝底色(#0F172A/#020617)、白色/浅灰背景
-- 渐变背景(linear-gradient)、圆角(16px)、阴影(blur 24px)
-- 全部色值来自 Tailwind CSS 默认色板
-- i18n 支持:zh / zh_Hant / en(与移动端一致)
-
-## 技术选型
-
-### 决策:Astro 5 + React + Tailwind CSS 4 + shadcn/ui
-
-### 选型理由
-
-#### 1. Astro 5 作为框架
-
-| 考量 | 结论 |
-|------|------|
-| 营销页 SEO | Astro 默认零 JS 输出,SSG 开箱即用,SEO 最优 |
-| Dashboard SPA | React islands 模式,客户端全功能渲染 |
-| 安全性 | 静态生成,无服务端运行时,攻击面极小 |
-| 无已知 CVE | 搜索未发现 Astro 框架本身的 CVE 记录 |
-
-**为什么不用 Next.js:**
-- CVE-2025-55182(CVSS 10.0 "React2Shell")RCE,已被武器化
-- CVE-2025-29927 中间件鉴权绕过
-- CVE-2025-66478 Next.js 对应编号,766 台主机被入侵
-- Server Components 攻击面大,对于本项目过度设计
-
-**为什么不用 Vite + React SPA:**
-- 营销页 SEO 差,需要额外 SSR 方案
-- 两个场景(营销+应用)用一套框架更简洁
-
-#### 2. React 作为交互层
-
-- shadcn/ui 原生 React,组件覆盖度最高
-- Astro islands 一等公民支持
-- 生态最大,Dashboard 复杂交互组件丰富
-
-**关于 CVE-2025-55182:**
-该漏洞仅影响 React Server Components(RSC),即 Next.js App Router 服务端渲染场景。我们的架构使用 Astro SSG + React 客户端 islands,不使用 RSC,漏洞不适用。
-
-#### 3. Tailwind CSS 4
-
-- 设计稿色值全部来自 Tailwind 色板,零适配成本
-- 渐变、flexbox、阴影、圆角全部原生支持
-- 无已知直接漏洞(Snyk 确认)
-- v4 已修复 v3 的 glob 传递依赖问题
-
-#### 4. shadcn/ui
-
-- 源码级复制,可完全自定义,无锁定
-- Card / Button / Input / Form / Sidebar 等组件与设计稿高度吻合
-- 无已知直接漏洞(Snyk 确认)
-- 只使用官方 registry,避免第三方 registry 注入风险
-
-### 架构概览
-
-```
-web/
-├── src/
-│ ├── components/ # 共享 UI 组件(shadcn/ui + 自定义)
-│ │ ├── ui/ # shadcn/ui 组件
-│ │ ├── Navbar.astro # 营销页导航栏
-│ │ ├── Footer.astro # 营销页页脚
-│ │ └── Sidebar.tsx # Dashboard 侧边栏(React island)
-│ ├── layouts/
-│ │ ├── Marketing.astro # 营销页布局(Navbar + Footer)
-│ │ └── Dashboard.astro # Dashboard 布局(Sidebar + Main)
-│ ├── pages/
-│ │ ├── index.astro # Landing Page
-│ │ ├── features.astro # Features Page
-│ │ ├── pricing.astro # Pricing Page
-│ │ ├── about.astro # About Page
-│ │ ├── privacy.astro # Privacy Policy
-│ │ ├── terms.astro # Terms of Service
-│ │ ├── login.astro # Login Page(含 React island)
-│ │ └── dashboard/
-│ │ ├── index.astro # Dashboard(含 React island)
-│ │ └── notifications.astro
-│ └── styles/
-│ └── global.css # Tailwind 入口
-├── astro.config.mjs
-├── tailwind.config.ts
-├── tsconfig.json
-└── package.json
-```
-
-### API 集成
-
-- 后端:FastAPI(已有)
-- Auth:Supabase Auth JS SDK(客户端)
-- 数据:通过 FastAPI REST API + Supabase JS SDK
-
-### i18n 方案
-
-- 使用 `astro-i18next` 或 `@astrolicious/i18n` 处理 Astro 页面
-- React islands 使用 `react-i18next`
-- 翻译文件与移动端共享(zh / zh_Hant / en)
-
-### 部署
-
-- 营销页:SSG 静态部署(Vercel / Cloudflare Pages / 自托管)
-- Dashboard SPA:同一构建产物,客户端渲染
-- 无需 Node.js 服务端运行时
-
-## 验收标准
-
-- [ ] 所有设计稿页面可在浏览器中像素级还原
-- [ ] 营销页 Lighthouse SEO 评分 >= 95
-- [ ] Dashboard 侧边栏导航和主区域交互正常
-- [ ] i18n 三语言切换正常
-- [ ] 无安全漏洞(npm audit clean)
-- [ ] 设计稿中所有渐变、阴影、圆角、布局精确匹配
diff --git a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/task.json b/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/task.json
deleted file mode 100644
index eccc9ca..0000000
--- a/.trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/task.json
+++ /dev/null
@@ -1,26 +0,0 @@
-{
- "id": "web-astro-react-tailwind-shadcn-ui",
- "name": "web-astro-react-tailwind-shadcn-ui",
- "title": "Web端技术选型:Astro + React + Tailwind + shadcn/ui",
- "description": "",
- "status": "in_progress",
- "dev_type": null,
- "scope": null,
- "package": null,
- "priority": "P2",
- "creator": "zl-q",
- "assignee": "zl-q",
- "createdAt": "2026-05-08",
- "completedAt": null,
- "branch": null,
- "base_branch": "dev",
- "worktree_path": null,
- "commit": null,
- "pr_url": null,
- "subtasks": [],
- "children": [],
- "parent": null,
- "relatedFiles": [],
- "notes": "",
- "meta": {}
-}
\ No newline at end of file
diff --git a/web/src/components/ManualDivinationPage.tsx b/web/src/components/ManualDivinationPage.tsx
index f6720b5..88282ea 100644
--- a/web/src/components/ManualDivinationPage.tsx
+++ b/web/src/components/ManualDivinationPage.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import Icon from './Icon';
interface Props {
@@ -7,166 +7,347 @@ interface Props {
divination: { questionTitle: string; questionPlaceholder: string; categoryLabel: string; categories: string; timeTitle: string; timeHint: string; guideTitle: string; guideManual: string; yaoTitle: string; coinLabel: string; confirmBtn: string; summaryTitle: string; checkCategory: string; checkMethod: string; checkCost: string; submitBtn: string; progressLabel: string };
}
-type CoinFace = '字' | '花';
+type CoinFace = 'zi' | 'hua';
+type YaoType = 'youngYang' | 'youngYin' | 'oldYang' | 'oldYin';
-function CoinImage({ face, size = 'w-16 h-16' }: { face: CoinFace; size?: string }) {
+const TOTAL_YAO_COUNT = 6;
+
+function formatDateTimeInput(value: Date): string {
+ const pad = (n: number) => String(n).padStart(2, '0');
+ return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`;
+}
+
+function fromHuaCount(huaCount: number): YaoType {
+ switch (huaCount) {
+ case 0:
+ return 'oldYin';
+ case 1:
+ return 'youngYang';
+ case 2:
+ return 'youngYin';
+ case 3:
+ return 'oldYang';
+ default:
+ throw new RangeError('huaCount must be 0..3');
+ }
+}
+
+function fromCoins(coins: CoinFace[]): YaoType {
+ return fromHuaCount(coins.filter((coin) => coin === 'hua').length);
+}
+
+function CoinImage({ face, selected }: { face: CoinFace; selected?: boolean }) {
return (
);
}
+function YaoGlyph({ type, active }: { type?: YaoType; active?: boolean }) {
+ const color = active || type ? 'bg-violet-700' : 'bg-slate-200';
+
+ if (!type || type === 'youngYang' || type === 'oldYang') {
+ return
;
+ }
+
+ return (
+
+ );
+}
+
+const copy = {
+ zh: {
+ title: '手动起卦',
+ subtitle: '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。',
+ balance: '可用 120 积分 · 本次 20 积分',
+ defaultQuestion: '我接下来三个月的事业发展需要注意什么?',
+ modify: '修改',
+ guideLines: ['从初爻开始,按从下往上的顺序记录。', '每一爻由三枚钱币的字面/花面组合决定。', '六爻完成后才可开始解卦。'],
+ openGuide: '查看手动起卦教程',
+ guideSteps: [
+ ['手动起卦', '准备三枚相同的钱币。每次记录一爻,按从下往上的顺序共记录六爻。'],
+ ['确认时间', '先确认起卦时间。如需调整,点击右侧「修改」。'],
+ ['依次录入六爻', '从初爻开始逐条选择,未完成前下一爻不可点。每条会弹出三枚钱币选择面板。'],
+ ['开始分析', '六爻都填完后,「开始解卦」按钮会高亮提示,点击即可解卦。'],
+ ],
+ closeGuide: '结束教程',
+ nextGuide: '下一步',
+ prevGuide: '上一步',
+ lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
+ pending: '待录入',
+ zi: '字',
+ hua: '花',
+ yaoTypeNames: { youngYang: '少阳', youngYin: '少阴', oldYang: '老阳', oldYin: '老阴' },
+ questionTypePrefix: '问题类型',
+ method: '起卦方式:手动起卦',
+ submit: '开始解卦',
+ },
+ zh_Hant: {
+ title: '手動起卦',
+ subtitle: '準備三枚相同的錢幣,從初爻到上爻依序錄入六次結果。',
+ balance: '可用 120 積分 · 本次 20 積分',
+ defaultQuestion: '我接下來三個月的事業發展需要注意什麼?',
+ modify: '修改',
+ guideLines: ['從初爻開始,按從下往上的順序記錄。', '每一爻由三枚錢幣的字面/花面組合決定。', '六爻完成後才可開始解卦。'],
+ openGuide: '查看手動起卦教程',
+ guideSteps: [
+ ['手動起卦', '準備三枚相同的錢幣。每次記錄一爻,按從下往上的順序共記錄六爻。'],
+ ['確認時間', '先確認起卦時間。如需調整,點擊右側「修改」。'],
+ ['依序錄入六爻', '從初爻開始逐條選擇,未完成前下一爻不可點擊。每條會彈出三枚錢幣選擇面板。'],
+ ['開始分析', '六爻都填完後,「開始解卦」按鈕會高亮提示,點擊即可解卦。'],
+ ],
+ closeGuide: '結束教程',
+ nextGuide: '下一步',
+ prevGuide: '上一步',
+ lineNames: ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'],
+ pending: '待錄入',
+ zi: '字',
+ hua: '花',
+ yaoTypeNames: { youngYang: '少陽', youngYin: '少陰', oldYang: '老陽', oldYin: '老陰' },
+ questionTypePrefix: '問題類型',
+ method: '起卦方式:手動起卦',
+ submit: '開始解卦',
+ },
+ en: {
+ title: 'Manual Casting',
+ subtitle: 'Prepare three identical coins and record six results from the first yao at the bottom to the top yao.',
+ balance: 'Available 120 credits · This reading 20 credits',
+ defaultQuestion: 'What should I pay attention to in my career development over the next three months?',
+ modify: 'Modify',
+ guideLines: ['Record from the first yao upward.', 'Each yao is determined by the text-side and flower-side combination of three coins.', 'Start interpretation after all six yao are complete.'],
+ openGuide: 'View Manual Casting Guide',
+ guideSteps: [
+ ['Manual Casting', 'Prepare three identical coins. Record one yao at a time, from bottom to top, until all six yao are complete.'],
+ ['Confirm Time', 'Check the casting time first. Use Modify on the right if you need to adjust it.'],
+ ['Fill Six Yao in Order', 'Start from the first yao and complete one row at a time. The next row stays locked until the current row is confirmed.'],
+ ['Start Interpretation', 'After all six yao are filled, Start Interpretation becomes active. Select it to continue.'],
+ ],
+ closeGuide: 'Finish',
+ nextGuide: 'Next',
+ prevGuide: 'Back',
+ lineNames: ['First Yao', 'Second Yao', 'Third Yao', 'Fourth Yao', 'Fifth Yao', 'Top Yao'],
+ pending: 'Pending',
+ zi: 'Text',
+ hua: 'Flower',
+ yaoTypeNames: { youngYang: 'Young Yang', youngYin: 'Young Yin', oldYang: 'Old Yang', oldYin: 'Old Yin' },
+ questionTypePrefix: 'Category',
+ method: 'Method: Manual Casting',
+ submit: 'Start Interpretation',
+ },
+} as const;
+
export default function ManualDivinationPage({ locale, divination: d }: Props) {
- const cats = d.categories.split(',');
+ const text = copy[locale as keyof typeof copy] ?? copy.zh;
+ const cats = useMemo(() => d.categories.split(','), [d.categories]);
const [category, setCategory] = useState(cats[0]);
- const [question, setQuestion] = useState('');
- const [yaoIndex, setYaoIndex] = useState(0);
- const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['字', '字', '字']);
- const [yaoResults, setYaoResults] = useState([]);
+ const [question, setQuestion] = useState(text.defaultQuestion);
+ const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
+ const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
+ const [yaoResults, setYaoResults] = useState([]);
+ const [guideStep, setGuideStep] = useState(null);
+
+ useEffect(() => {
+ setCategory(cats[0]);
+ }, [cats]);
+
+ const progress = yaoResults.length;
+ const currentYaoType = fromCoins(coins);
+ const guideOpen = guideStep !== null;
+ const guide = guideOpen ? text.guideSteps[guideStep] : null;
const flipCoin = (idx: number) => {
- const next: [CoinFace, CoinFace, CoinFace] = [...coins];
- next[idx] = next[idx] === '字' ? '花' : '字';
- setCoins(next);
+ setCoins((current) => {
+ const next = [...current] as [CoinFace, CoinFace, CoinFace];
+ next[idx] = next[idx] === 'zi' ? 'hua' : 'zi';
+ return next;
+ });
};
const confirmYao = () => {
- const newResults = [...yaoResults, [...coins]];
- setYaoResults(newResults);
- if (newResults.length < 6) {
- setYaoIndex(newResults.length);
- setCoins(['字', '字', '字']);
- }
+ if (progress >= TOTAL_YAO_COUNT) return;
+ setYaoResults((current) => [...current, currentYaoType]);
+ setCoins(['zi', 'zi', 'zi']);
};
- const progress = yaoResults.length;
- const isYang = (c: CoinFace[]) => c.filter(x => x === '字').length % 2 === 1;
- const yaoNames = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'];
+ const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0)));
+ const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
return (
-
-
-
-
{locale === 'en' ? 'Manual Cast' : d.checkMethod.replace(/^.*:|^.*: /, '')}
-
{locale === 'en' ? 'Prepare three matching coins and enter six results from bottom line to top line.' : '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。'}
-
-
-
- {locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'}
-
+
+
+
+
{text.title}
+
{text.subtitle}
-
- {/* Left: Question + Time + Guide */}
-
- {/* Question panel */}
-
-
{d.questionTitle}
-
-
{category}
-
+
+
+ {text.balance}
+
+
+
+
+
+
+ {d.questionTitle}
+
+
+
+
+
- {/* Time panel */}
-
-
{d.timeTitle}
-
-
-
-
-
- {/* Guide panel */}
-
-
{d.guideTitle}
-
{d.guideManual}
-
+
+
+
+ {d.guideTitle}
+ {text.guideLines.map((line) => {line}
)}
+
+
- {/* Center: Yao panel */}
-
-
-
{d.yaoTitle}
-
{progress} / 6
+
+
+
{d.yaoTitle}
+ {progress} / {TOTAL_YAO_COUNT}
- {/* 6 Yao rows - from bottom to top (上爻 at top) */}
- {[5, 4, 3, 2, 1, 0].map((i) => {
- const result = yaoResults[i];
- const isCurrent = i === yaoIndex && progress < 6;
- const isDone = result !== undefined;
+ {[5, 4, 3, 2, 1, 0].map((index) => {
+ const result = yaoResults[index];
+ const active = index === progress && progress < TOTAL_YAO_COUNT;
return (
-
-
{yaoNames[i]}
- {isDone ? (
-
- {result.map((face, ci) => (
-
- ))}
-
- ) : (
-
待录入
- )}
- {isDone && (
-
- {isYang(result) ? (
-
- ) : (
-
- )}
-
- )}
+
+
{text.lineNames[index]}
+
+
+
+
+ {result ? text.yaoTypeNames[result] : text.pending}
+
);
})}
- {/* Coin selector */}
- {progress < 6 && (
-
-
- {coins.map((face, ci) => (
-
flipCoin(ci)} style={{ cursor: 'pointer', width: '86px' }}>
-
-
-
-
{face === '字' ? '正面' : '反面'}
-
- ))}
+ {progress < TOTAL_YAO_COUNT && (
+ <>
+
+
+ {coins.map((face, index) => (
+
+ ))}
+
-
- )}
- {progress < 6 && (
-
- )}
-
+
+ {text.yaoTypeNames[currentYaoType]}
+
- {/* Right: Summary */}
-
-
{d.summaryTitle}
- {/* Progress */}
-
-
{d.progressLabel}
-
{progress} / 6
+
+ >
+ )}
+
+
+
+
+
+ {guideOpen && guide && (
+
+
+
+ {guideStep + 1} / {text.guideSteps.length}
+
+
+
{guide[0]}
+
{guide[1]}
+
+
+ {guideStep === text.guideSteps.length - 1 ? (
+
+ ) : (
+
+ )}
+
+
-
+ )}
);
}