chore: clean up stale task files and update ManualDivinationPage

This commit is contained in:
zl-q
2026-05-09 18:24:35 +08:00
parent 1fbb07f692
commit 1d5efb46e7
5 changed files with 304 additions and 291 deletions
@@ -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"}
@@ -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"}
@@ -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-55182CVSS 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 ComponentsRSC),即 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(已有)
- AuthSupabase 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
- [ ] 设计稿中所有渐变、阴影、圆角、布局精确匹配
@@ -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": {}
}
+304 -123
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import Icon from './Icon'; import Icon from './Icon';
interface Props { 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 }; 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 ( return (
<img <img
src={face === '' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'} src={face === 'zi' ? '/images/qigua/zi.jpg' : '/images/qigua/hua.jpg'}
alt={face} alt={face === 'zi' ? '字' : '花'}
className={`${size} rounded-full object-cover border border-amber-300 shadow-sm`} className={`h-20 w-20 rounded-full object-cover shadow-sm transition-transform ${selected ? 'ring-2 ring-violet-600 ring-offset-2 ring-offset-slate-50' : ''}`}
draggable={false}
/> />
); );
} }
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 <div className={`h-2.5 w-full rounded-full ${color}`} />;
}
return (
<div className="flex h-2.5 w-full gap-4">
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
<div className={`h-2.5 flex-1 rounded-full ${color}`} />
</div>
);
}
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) { 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 [category, setCategory] = useState(cats[0]);
const [question, setQuestion] = useState(''); const [question, setQuestion] = useState(text.defaultQuestion);
const [yaoIndex, setYaoIndex] = useState(0); const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['', '', '']); const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
const [yaoResults, setYaoResults] = useState<CoinFace[][]>([]); const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
const [guideStep, setGuideStep] = useState<number | null>(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 flipCoin = (idx: number) => {
const next: [CoinFace, CoinFace, CoinFace] = [...coins]; setCoins((current) => {
next[idx] = next[idx] === '字' ? '花' : '字'; const next = [...current] as [CoinFace, CoinFace, CoinFace];
setCoins(next); next[idx] = next[idx] === 'zi' ? 'hua' : 'zi';
return next;
});
}; };
const confirmYao = () => { const confirmYao = () => {
const newResults = [...yaoResults, [...coins]]; if (progress >= TOTAL_YAO_COUNT) return;
setYaoResults(newResults); setYaoResults((current) => [...current, currentYaoType]);
if (newResults.length < 6) { setCoins(['zi', 'zi', 'zi']);
setYaoIndex(newResults.length);
setCoins(['字', '字', '字']);
}
}; };
const progress = yaoResults.length; const showPreviousGuide = () => setGuideStep((step) => (step === null ? 0 : Math.max(step - 1, 0)));
const isYang = (c: CoinFace[]) => c.filter(x => x === '字').length % 2 === 1; const showNextGuide = () => setGuideStep((step) => (step === null ? 0 : Math.min(step + 1, text.guideSteps.length - 1)));
const yaoNames = ['初爻', '二爻', '三爻', '四爻', '五爻', '上爻'];
return ( return (
<div className="flex flex-col gap-[22px] min-h-full"> <div className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-5">
<div> <div className="min-w-0">
<h1 className="text-[#333333] text-[28px] font-bold leading-tight">{locale === 'en' ? 'Manual Cast' : d.checkMethod.replace(/^.*|^.*: /, '')}</h1> <h1 className="text-[28px] font-bold leading-tight text-[#333333]">{text.title}</h1>
<p className="text-[#666666] text-sm mt-1">{locale === 'en' ? 'Prepare three matching coins and enter six results from bottom line to top line.' : '准备三枚相同的钱币,从初爻到上爻依次录入六次结果。'}</p> <p className="mt-1 text-sm text-[#666666]">{text.subtitle}</p>
</div>
<div className="hidden md:flex items-center gap-2 h-10 px-3.5 rounded-full bg-white border border-slate-200 text-[#333333] text-[13px] font-semibold">
<Icon name="paid" className="w-[18px] h-[18px] text-violet-700" />
{locale === 'en' ? 'Available 120 credits · This time 20 credits' : '可用 120 积分 · 本次 20 积分'}
</div>
</div> </div>
<div className="flex flex-col xl:flex-row gap-[22px] min-h-0 flex-1"> <div className="hidden h-10 items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 text-[13px] font-semibold text-[#333333] md:flex">
{/* Left: Question + Time + Guide */} <Icon name="paid" className="h-[18px] w-[18px] text-violet-700" />
<div className="w-full xl:w-[360px] flex flex-col gap-4 shrink-0"> {text.balance}
{/* Question panel */} </div>
<div className="bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-4" style={{ height: '300px' }}> </div>
<h3 className="text-slate-900 text-lg font-bold">{d.questionTitle}</h3>
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50 border border-slate-300"> <div className="flex min-h-0 flex-1 flex-col gap-[22px] xl:flex-row">
<span className="text-slate-600 text-sm">{category}</span> <div className="flex w-full shrink-0 flex-col gap-4 xl:w-[360px]">
<select value={category} onChange={e => setCategory(e.target.value)} className="bg-transparent text-sm outline-none cursor-pointer"> <section className="flex h-[300px] flex-col gap-4 rounded-2xl border border-slate-200 bg-white p-[22px]">
{cats.map(c => <option key={c} value={c}>{c}</option>)} <h2 className="text-lg font-bold text-slate-900">{d.questionTitle}</h2>
</select> <label className="sr-only" htmlFor="manual-category">{d.categoryLabel}</label>
<select
id="manual-category"
value={category}
onChange={(event) => setCategory(event.target.value)}
className="h-[42px] rounded-[10px] border border-slate-300 bg-slate-50 px-3 text-sm font-bold text-[#333333] outline-none focus:border-violet-500"
>
{cats.map((cat) => <option key={cat} value={cat}>{cat}</option>)}
</select>
<textarea
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder={d.questionPlaceholder}
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
/>
</section>
<section className={`flex h-[132px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 1 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
<h2 className="text-base font-bold text-slate-900">{d.timeTitle}</h2>
<div className="flex h-[42px] items-center justify-between gap-3 rounded-[10px] bg-slate-50 px-3">
<input
type="datetime-local"
value={selectedTime}
onChange={(event) => setSelectedTime(event.target.value)}
className="w-full bg-transparent text-sm font-semibold text-[#333333] outline-none"
/>
<span className="shrink-0 text-[13px] font-bold text-violet-700">{text.modify}</span>
</div> </div>
<textarea value={question} onChange={e => setQuestion(e.target.value)} placeholder={d.questionPlaceholder} </section>
className="flex-1 w-full px-3.5 py-3 rounded-[10px] bg-white border border-slate-300 text-sm focus:outline-none focus:border-violet-400 resize-none" />
</div> <section className={`flex h-[214px] flex-col gap-3 rounded-2xl border bg-white p-5 ${guideStep === 0 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
{/* Time panel */} <h2 className="text-base font-bold text-slate-900">{d.guideTitle}</h2>
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3" style={{ height: '132px' }}> {text.guideLines.map((line) => <p key={line} className="text-[13px] leading-relaxed text-[#666666]">{line}</p>)}
<h3 className="text-slate-900 text-base font-bold">{d.timeTitle}</h3> <button
<div className="flex items-center justify-between h-[42px] px-3 rounded-[10px] bg-slate-50"> type="button"
<input type="datetime-local" className="bg-transparent text-sm outline-none w-full" /> onClick={() => setGuideStep(0)}
<Icon name="calendar_today" className="w-[18px] h-[18px] text-slate-400" /> className="mt-auto flex h-8 w-fit items-center gap-2 rounded-full bg-violet-50 px-3 text-[13px] font-bold text-violet-700"
</div> >
</div> <Icon name="notifications" className="h-[18px] w-[18px]" />
{/* Guide panel */} {text.openGuide}
<div className="bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-3 flex-1 overflow-y-auto" style={{ height: '214px' }}> </button>
<h3 className="text-slate-900 text-base font-bold">{d.guideTitle}</h3> </section>
<p className="text-slate-500 text-[13px] whitespace-pre-line">{d.guideManual}</p>
</div>
</div> </div>
{/* Center: Yao panel */} <section className={`flex min-w-0 flex-1 flex-col gap-4 rounded-2xl border bg-white p-6 ${guideStep === 2 ? 'border-violet-500 ring-4 ring-violet-100' : 'border-slate-200'}`}>
<div className="flex-1 bg-white rounded-2xl p-6 border border-slate-200 flex flex-col gap-4 overflow-y-auto"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-between"> <h2 className="text-lg font-bold text-slate-900">{d.yaoTitle}</h2>
<h3 className="text-slate-900 text-lg font-bold">{d.yaoTitle}</h3> <span className="text-[13px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</span>
<span className="text-violet-600 text-[13px] font-bold">{progress} / 6</span>
</div> </div>
{/* 6 Yao rows - from bottom to top (上爻 at top) */}
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{[5, 4, 3, 2, 1, 0].map((i) => { {[5, 4, 3, 2, 1, 0].map((index) => {
const result = yaoResults[i]; const result = yaoResults[index];
const isCurrent = i === yaoIndex && progress < 6; const active = index === progress && progress < TOTAL_YAO_COUNT;
const isDone = result !== undefined;
return ( return (
<div key={i} <div
className={`flex items-center gap-4 h-[62px] px-3.5 rounded-[10px] ${isCurrent ? 'bg-violet-50 border border-violet-400' : isDone ? 'bg-slate-50' : 'bg-slate-50 border border-slate-200'}`}> key={index}
<span className="text-slate-400 text-xs font-medium w-8">{yaoNames[i]}</span> className={`flex h-[62px] items-center gap-4 rounded-[10px] px-3.5 ${active ? 'border border-violet-600 bg-violet-50' : result ? 'border border-slate-200 bg-white' : 'bg-slate-50'}`}
{isDone ? ( >
<div className="flex gap-2"> <span className={`w-16 text-sm font-bold ${active || result ? 'text-violet-700' : 'text-slate-400'}`}>{text.lineNames[index]}</span>
{result.map((face, ci) => ( <div className="min-w-0 flex-1">
<CoinImage key={ci} face={face} size="w-8 h-8" /> <YaoGlyph type={result} active={active} />
))} </div>
</div> <span className={`w-20 text-right text-[13px] font-bold ${result ? 'text-violet-700' : 'text-slate-400'}`}>
) : ( {result ? text.yaoTypeNames[result] : text.pending}
<span className="text-slate-300 text-xs"></span> </span>
)}
{isDone && (
<div className="ml-auto">
{isYang(result) ? (
<div className="w-12 h-2 bg-violet-600 rounded" />
) : (
<div className="flex gap-1.5"><div className="w-4 h-2 bg-violet-400 rounded" /><div className="w-4 h-2 bg-violet-400 rounded" /></div>
)}
</div>
)}
</div> </div>
); );
})} })}
</div> </div>
{/* Coin selector */} {progress < TOTAL_YAO_COUNT && (
{progress < 6 && ( <>
<div className="bg-slate-50 rounded-xl p-4 flex flex-col items-center gap-4" style={{ minHeight: '142px' }}> <div className="flex min-h-[142px] items-center justify-center rounded-xl bg-slate-50 p-4">
<div className="flex items-center gap-6"> <div className="flex items-center justify-center gap-6">
{coins.map((face, ci) => ( {coins.map((face, index) => (
<div key={ci} className="flex flex-col items-center gap-2" onClick={() => flipCoin(ci)} style={{ cursor: 'pointer', width: '86px' }}> <button
<div className={`w-16 h-16 rounded-full border-2 flex items-center justify-center text-lg font-bold transition-all cursor-pointer select-none ${face === '字' ? 'bg-amber-100 border-amber-400 text-amber-700' : 'bg-slate-200 border-slate-400 text-slate-600'}`}> key={index}
<CoinImage face={face} /> type="button"
</div> onClick={() => flipCoin(index)}
<span className="text-slate-400 text-xs">{face === '字' ? '正面' : '反面'}</span> className="flex w-20 flex-col items-center gap-2 text-[13px] font-bold text-slate-600"
</div> >
))} <CoinImage face={face} selected={face === 'hua'} />
<span>{face === 'zi' ? text.zi : text.hua}</span>
</button>
))}
</div>
</div> </div>
</div>
)}
{progress < 6 && ( <div className="rounded-xl border border-violet-100 bg-violet-50 px-4 py-3 text-center text-[13px] font-semibold text-violet-800">
<button onClick={confirmYao} className="w-full h-10 rounded-xl bg-violet-600 text-white text-[13px] font-bold hover:bg-violet-700 transition-colors">{d.confirmBtn}</button> {text.yaoTypeNames[currentYaoType]}
)} </div>
</div>
{/* Right: Summary */} <button
<div className="w-full xl:w-[300px] bg-white rounded-2xl p-[22px] border border-slate-200 flex flex-col gap-[18px] shrink-0"> type="button"
<h3 className="text-slate-900 text-lg font-bold">{d.summaryTitle}</h3> onClick={confirmYao}
{/* Progress */} className="h-10 w-full rounded-full bg-violet-700 text-[13px] font-bold text-white transition-colors hover:bg-violet-800"
<div className="bg-slate-50 rounded-xl p-4 flex flex-col gap-2"> >
<p className="text-slate-500 text-[13px]">{d.progressLabel}</p> {d.confirmBtn}
<p className="text-violet-600 text-[28px] font-bold">{progress} / 6</p> </button>
</>
)}
</section>
<aside className="flex w-full shrink-0 flex-col gap-[18px] rounded-2xl border border-slate-200 bg-white p-[22px] xl:w-[300px]">
<h2 className="text-lg font-bold text-slate-900">{d.summaryTitle}</h2>
<div className="flex h-[94px] flex-col gap-2 rounded-xl bg-slate-50 p-4">
<p className="text-[13px] text-[#666666]">{d.progressLabel}</p>
<p className="text-[28px] font-bold text-violet-700">{progress} / {TOTAL_YAO_COUNT}</p>
</div> </div>
<p className="text-slate-500 text-sm">{d.checkCategory}</p> <p className="text-sm text-[#666666]">{text.questionTypePrefix}{locale === 'en' ? ': ' : ''}{category}</p>
<p className="text-slate-500 text-sm">{d.checkMethod}</p> <p className="text-sm text-[#666666]">{text.method}</p>
<p className="text-slate-500 text-sm">{d.checkCost}</p> <p className="text-sm text-[#666666]">{d.checkCost}</p>
<div className="flex-1" /> <div className="flex-1" />
<button disabled={progress < 6} <button
className={`w-full h-[46px] rounded-full text-sm font-bold transition-colors ${progress >= 6 ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-slate-300 text-slate-400 cursor-not-allowed'}`}> type="button"
{d.submitBtn} disabled={progress < TOTAL_YAO_COUNT}
className={`h-[46px] w-full rounded-full text-sm font-bold transition-colors ${progress >= TOTAL_YAO_COUNT ? 'bg-violet-700 text-white hover:bg-violet-800' : 'cursor-not-allowed bg-slate-300 text-slate-400'} ${guideStep === 3 ? 'ring-4 ring-violet-100' : ''}`}
>
{text.submit}
</button> </button>
</aside>
</div>
{guideOpen && guide && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-5">
<div className="w-full max-w-md rounded-2xl border border-white/10 bg-slate-950 p-6 text-white shadow-2xl">
<div className="mb-4 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-violet-200">{guideStep + 1} / {text.guideSteps.length}</span>
<button type="button" onClick={() => setGuideStep(null)} className="rounded-full p-1 text-white/70 hover:bg-white/10 hover:text-white">
<Icon name="close" className="h-5 w-5" />
</button>
</div>
<h2 className="text-xl font-bold">{guide[0]}</h2>
<p className="mt-3 text-sm leading-6 text-white/80">{guide[1]}</p>
<div className="mt-6 flex items-center justify-between gap-3">
<button type="button" onClick={showPreviousGuide} disabled={guideStep === 0} className="h-10 rounded-full px-4 text-sm font-bold text-white/70 disabled:opacity-40">
{text.prevGuide}
</button>
{guideStep === text.guideSteps.length - 1 ? (
<button type="button" onClick={() => setGuideStep(null)} className="h-10 rounded-full bg-white px-5 text-sm font-bold text-slate-950">
{text.closeGuide}
</button>
) : (
<button type="button" onClick={showNextGuide} className="h-10 rounded-full bg-white px-5 text-sm font-bold text-slate-950">
{text.nextGuide}
</button>
)}
</div>
</div>
</div> </div>
</div> )}
</div> </div>
); );
} }