From 1d5efb46e757c371dd43ee3a56c87001f8a8dd53 Mon Sep 17 00:00:00 2001 From: zl-q Date: Sat, 9 May 2026 18:24:35 +0800 Subject: [PATCH] chore: clean up stale task files and update ManualDivinationPage --- .../check.jsonl | 2 - .../implement.jsonl | 3 - .../prd.md | 137 ------ .../task.json | 26 -- web/src/components/ManualDivinationPage.tsx | 427 +++++++++++++----- 5 files changed, 304 insertions(+), 291 deletions(-) delete mode 100644 .trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/check.jsonl delete mode 100644 .trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/implement.jsonl delete mode 100644 .trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/prd.md delete mode 100644 .trellis/tasks/05-08-web-astro-react-tailwind-shadcn-ui/task.json 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 ( {face} ); } +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}

+ + +