diff --git a/README.md b/README.md index bbbfda0..9892a28 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,15 @@ # StudyFlow +## 视觉设计样板 + +当前的「自然沉浸与正念成长」视觉样板通过以下地址访问: + +```text +http://127.0.0.1:5173/?design-preview=1 +``` + +样板使用本地假数据,不读取或写入 StudyFlow 的 IndexedDB。Today、Plan、Focus、Meditation 和成长阶段仅用于设计确认;正式界面会在样板确认后分阶段接入。 + StudyFlow 是一个 Web-first 的个人学习计划与执行助手。V2 已形成第一阶段的 `Plan → Execute → Record`:先安排学习任务,再用正计时或番茄钟执行,并把实际投入和结果保存为可审计的本地记录。 ## 主要功能 diff --git a/docs/design-preview/ASSET_PROMPTS.md b/docs/design-preview/ASSET_PROMPTS.md new file mode 100644 index 0000000..b78b8e5 --- /dev/null +++ b/docs/design-preview/ASSET_PROMPTS.md @@ -0,0 +1,38 @@ +# 原创背景资产生成记录 + +生成方式:Codex 内置 `imagegen`,`photorealistic-natural` 模式。植物与花朵没有使用图片生成,均为项目内可维护的 SVG。 + +## Focus 雾林 + +输出:`src/assets/nature/focus-forest.webp` + +```text +Use case: photorealistic-natural +Asset type: full-screen desktop web app Focus background +Primary request: an original quiet misty forest scene for a study focus timer, with softly blurred leaves and fine branches framing the scene and calm atmospheric depth +Scene/backdrop: early morning woodland with light fog, natural foliage, no buildings +Style/medium: photorealistic natural photography with subtle film texture, refined but not glossy +Composition/framing: wide landscape composition; generous uncluttered negative space through the center for an overlaid plant illustration, timer, and controls; foliage may frame the outer edges but must not create a strong focal object +Lighting/mood: soft diffused dawn light, meditative, quiet, restrained, low contrast +Color palette: deep ink green #10241E, moss green #294C40, muted sage #6F9280, small touches of warm gray; low saturation +Constraints: background image only; no UI; no people; no animals; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition +Avoid: neon green, bright sun, fantasy glow, dramatic landscape, colorful flowers, high contrast, busy detail, obvious stock-photo polish +``` + +## Meditation 晨雾水面 + +输出:`src/assets/nature/meditation-water.webp` + +```text +Use case: photorealistic-natural +Asset type: full-screen desktop web app Meditation background +Primary request: an original tranquil waterside dawn scene for a meditation timer, with still water, delicate mist, soft reeds and barely visible distant foliage +Scene/backdrop: quiet natural pond or slow lake at first light, no structures +Style/medium: photorealistic natural photography with subtle film texture, refined and understated +Composition/framing: wide landscape composition; calm uncluttered negative space through the center for an overlaid breathing halo, flower illustration, timer, and controls; subtle natural framing only at far edges +Lighting/mood: diffused pale morning light through mist, serene, contemplative, soft and low contrast +Color palette: muted warm gray, deep ink green #10241E, sage #6F9280, soft stone and a restrained trace of warm gold #D7B77A; low saturation +Materials/textures: gentle water reflection, realistic fine mist, soft organic reeds +Constraints: background image only; no UI; no people; no animals; no flowers as focal subjects; no text; no logos; no watermark; suitable for a dark translucent overlay; original composition +Avoid: vivid sunrise, orange sky, neon teal, dramatic mountains, fantasy glow, colorful flowers, busy detail, high contrast, obvious stock-photo polish +``` diff --git a/docs/design-preview/DESIGN_SYSTEM.md b/docs/design-preview/DESIGN_SYSTEM.md new file mode 100644 index 0000000..c4a4515 --- /dev/null +++ b/docs/design-preview/DESIGN_SYSTEM.md @@ -0,0 +1,117 @@ +# StudyFlow「自然沉浸与正念成长」视觉设计系统 + +状态:视觉方向已确认,第二阶段已将设计 token 与 Today、Plan、Focus 接入正式页面。该目录中的 `?design-preview=1` 截图仍使用假数据;成长记录、今日花园和 Meditation 尚未接入正式数据。 + +## 1. 设计原则 + +StudyFlow 是一个安静、自然、克制、有呼吸感的学习与正念空间。 + +- 信息优先:任务标题、计时和主要操作始终比装饰更清晰。 +- 自然但不拟物:自然照片只建立氛围,成长植物使用柔和分层 SVG,不模仿 Forest 的游戏视觉。 +- 成长但不奖惩:植物记录真实投入,不枯萎、不用金币、连续签到或强烈庆祝制造压力。 +- 沉浸但可退出:Focus 与 Meditation 可以降低视觉噪音,但暂停、结束和返回始终清晰可见。 + +## 2. Design tokens + +| 用途 | Token | 值 | +| --- | --- | --- | +| 浅色主背景 | `--sf-mist` | `#F3F5F1` | +| 暖色辅助背景 | `--sf-warm` | `#F5F1E9` | +| 主文字 | `--sf-ink` | `#18231E` | +| 品牌鼠尾草绿 | `--sf-sage` | `#6F9280` | +| 柔光绿 | `--sf-sage-soft` | `#9AB9A8` | +| 深色沉浸背景 | `--sf-forest` | `#10241E` | +| 深色辅助层 | `--sf-moss` | `#294C40` | +| 到时与超时提示 | `--sf-gold` | `#D7B77A` | + +圆角分为四级:控件 `12px`、任务卡 `18px`、主面板 `24px`、沉浸控制区 `32px`。不要为了“更圆”随意增加新级别。 + +## 3. 毛玻璃与背景边界 + +允许使用毛玻璃: + +- Today 的主统计与今日花园 +- Plan 的筛选工具栏 +- 全局活动计时栏 +- 弹窗 +- Focus 与 Meditation 的沉浸控制区 + +不使用或只使用近实色: + +- 任务卡和四象限内的密集内容 +- 表单输入、选择器和长文本 +- History 的记录列表 +- 需要连续阅读的说明文字 + +浅色玻璃为约 62% 白色与 `18px` blur;深色玻璃为约 48% 黑绿与 `24px` blur。无 `backdrop-filter` 时分别降级为 `#F7F8F5` 与 `#18332A` 实色。 + +自然照片只用于 Focus、Meditation 和少量冥想入口 Hero。Today 与 Plan 使用抽象雾化渐变,避免装饰干扰优先级判断。 + +## 4. 代表页面 + +### Today + +- 第一屏左侧显示计划、完成、剩余、实际专注和进行中的会话。 +- 右侧“今日花园”直接显示最近植物,正式接入时最多 8 株。 +- 任务列表保持近实色与高对比度。 +- 移动端改为单列,主操作收成圆角图标按钮。 + +### Plan + +- 四象限与列表切换、分类/状态/日期筛选保持现有能力。 +- 筛选只使用一层玻璃;象限使用低饱和红、蓝、琥珀、绿。 +- 象限任务卡保持近实色,不叠加多层透明效果。 + +### Focus + +- 本地雾林背景上覆盖深绿渐变,保证中心文字对比。 +- 植物、任务、计时和控制区沿同一视觉轴排列。 +- 运行、暂停和超时都有明确文字;暖金只用于到时/超时提醒。 +- 植物动画不遮挡计时,`prefers-reduced-motion` 下停止非必要动画。 + +### Meditation + +- 本地晨雾水面背景比 Focus 更柔和、更开放。 +- 设置、呼吸、冥想、复盘是四个连续但可跳过的状态。 +- 呼吸光圈只有缓慢缩放和文字提示,不依赖声音表达阶段。 +- 感受选择不使用“好/坏”评价,不强迫填写备注。 + +## 5. 成长系统 + +学习生成树,冥想生成花。五阶段为: + +1. `<10%`:种子 +2. `10–34%`:嫩芽 +3. `35–64%`:幼苗 +4. `65–99%`:小树或花苞 +5. `≥100%`:成熟树或盛开 + +植物阶段跟随有效时间与目标快照动态计算。提前结束或未完成不会枯萎;成熟后超时只增加少量叶片或柔光。低饱和变体应由会话 ID 稳定生成,备份与刷新后不能改变。 + +## 6. Meditation 操作流程 + +```text +Today / 侧边栏 / 快速开始 + → 选择定时或自由计时 + → 选择意图 + → 选择呼吸引导 + → 呼吸引导(可跳过) + → 冥想计时(暂停 / 恢复 / 提前结束) + → 定时结束响铃 + → 超时正计时,直到主动结束 + → 五档感受与可选备注 + → 保存或跳过复盘并保存 + → 生成冥想花,并进入 History 与今日花园 +``` + +呼吸引导阶段不计入核心冥想时长。正式实现必须复用现有通知、音量、多标签同步、刷新恢复与休眠确认能力,并保证全局只有一个学习或冥想活动会话。 + +## 7. 实施门禁 + +当前已完成设计确认样板和第 1 项正式视觉改造,后续依次实施: + +1. ~~正式视觉 token 与 Today / Plan / Focus 改造~~ +2. IndexedDB version 3、备份 version 3、成长记录与今日花园 +3. Meditation 数据层、完整流程、History 类型隔离 + +每阶段单独验收,禁止在视觉改造阶段顺带修改正式数据模型。 diff --git a/docs/design-preview/screenshots/focus-desktop.png b/docs/design-preview/screenshots/focus-desktop.png new file mode 100644 index 0000000..c13d566 Binary files /dev/null and b/docs/design-preview/screenshots/focus-desktop.png differ diff --git a/docs/design-preview/screenshots/focus-mobile.png b/docs/design-preview/screenshots/focus-mobile.png new file mode 100644 index 0000000..e4b5e6e Binary files /dev/null and b/docs/design-preview/screenshots/focus-mobile.png differ diff --git a/docs/design-preview/screenshots/growth-desktop.png b/docs/design-preview/screenshots/growth-desktop.png new file mode 100644 index 0000000..e246bc2 Binary files /dev/null and b/docs/design-preview/screenshots/growth-desktop.png differ diff --git a/docs/design-preview/screenshots/growth-mobile.png b/docs/design-preview/screenshots/growth-mobile.png new file mode 100644 index 0000000..c85f26d Binary files /dev/null and b/docs/design-preview/screenshots/growth-mobile.png differ diff --git a/docs/design-preview/screenshots/meditation-desktop.png b/docs/design-preview/screenshots/meditation-desktop.png new file mode 100644 index 0000000..14c0628 Binary files /dev/null and b/docs/design-preview/screenshots/meditation-desktop.png differ diff --git a/docs/design-preview/screenshots/meditation-mobile.png b/docs/design-preview/screenshots/meditation-mobile.png new file mode 100644 index 0000000..17c37ee Binary files /dev/null and b/docs/design-preview/screenshots/meditation-mobile.png differ diff --git a/docs/design-preview/screenshots/plan-desktop.png b/docs/design-preview/screenshots/plan-desktop.png new file mode 100644 index 0000000..2413d02 Binary files /dev/null and b/docs/design-preview/screenshots/plan-desktop.png differ diff --git a/docs/design-preview/screenshots/plan-mobile.png b/docs/design-preview/screenshots/plan-mobile.png new file mode 100644 index 0000000..4fe4764 Binary files /dev/null and b/docs/design-preview/screenshots/plan-mobile.png differ diff --git a/docs/design-preview/screenshots/today-desktop.png b/docs/design-preview/screenshots/today-desktop.png new file mode 100644 index 0000000..f960415 Binary files /dev/null and b/docs/design-preview/screenshots/today-desktop.png differ diff --git a/docs/design-preview/screenshots/today-mobile.png b/docs/design-preview/screenshots/today-mobile.png new file mode 100644 index 0000000..fea2999 Binary files /dev/null and b/docs/design-preview/screenshots/today-mobile.png differ diff --git a/e2e/design-preview.spec.ts b/e2e/design-preview.spec.ts new file mode 100644 index 0000000..b63b918 --- /dev/null +++ b/e2e/design-preview.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; + +test.describe("隔离设计样板", () => { + test("可以浏览代表页面并且不会创建正式数据库", async ({ page }) => { + await page.goto("/?design-preview=1"); + + await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible(); + await expect(page.getByText("今日花园")).toBeVisible(); + expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0); + + await page.getByRole("button", { name: "Plan", exact: true }).first().click(); + await expect(page.getByRole("heading", { name: "把精力放在真正重要的地方" })).toBeVisible(); + + await page.getByRole("button", { name: "Focus", exact: true }).first().click(); + await page.getByRole("button", { name: "暂停", exact: true }).click(); + await expect(page.getByText("已暂停 · 植物也在休息")).toBeVisible(); + await page.getByRole("button", { name: "超时", exact: true }).click(); + await expect(page.getByText("本轮已完成 · 超时专注")).toBeVisible(); + + await page.getByRole("button", { name: "Meditation", exact: true }).click(); + await expect(page.getByRole("heading", { name: "此刻,你需要怎样的停留?" })).toBeVisible(); + await page.getByRole("button", { name: "开始这次停留" }).click(); + await expect(page.getByText("吸气")).toBeVisible(); + await page.getByRole("button", { name: "冥想", exact: true }).click(); + await expect(page.getByText("安静地停留")).toBeVisible(); + + expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(0); + }); + + test("手机预览使用 390×844 设计画布", async ({ page }) => { + await page.goto("/?design-preview=1&screen=today"); + await page.getByRole("button", { name: "手机宽度" }).click(); + const viewport = page.locator(".preview-viewport"); + await expect(viewport).toHaveCSS("width", "390px"); + await expect(viewport).toHaveCSS("height", "844px"); + await expect(page.locator(".mobile-nav")).toBeVisible(); + }); +}); diff --git a/e2e/visual-system.spec.ts b/e2e/visual-system.spec.ts new file mode 100644 index 0000000..0ae02f0 --- /dev/null +++ b/e2e/visual-system.spec.ts @@ -0,0 +1,116 @@ +import { expect, test, type Page } from "@playwright/test"; + +const FIXED_TIME = new Date("2026-08-15T03:00:00.000Z"); + +async function createTask(page: Page, title: string, category: string, minutes: number, important: boolean, urgent: boolean) { + await page.getByRole("button", { name: "新建任务" }).click(); + await page.getByLabel("任务标题").fill(title); + await page.getByLabel("所属分类").selectOption({ label: category }); + await page.getByLabel("预计完成时长").fill(String(minutes)); + await page.getByLabel("截止日期").fill("2026-08-15"); + if (important) await page.getByRole("checkbox", { name: /^重要 / }).check(); + if (urgent) await page.getByRole("checkbox", { name: /^紧急 / }).check(); + await page.getByRole("button", { name: "保存" }).click(); +} + +test("Today、Plan、Focus 的正式视觉在桌面和手机宽度保持稳定", async ({ page }) => { + test.setTimeout(60_000); + await page.clock.install({ time: FIXED_TIME }); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/"); + await page.getByRole("link", { name: "Plan" }).click(); + + await createTask(page, "高数:复习微分中值定理", "高数", 45, true, true); + await createTask(page, "CS50 Week 4 笔记整理", "CS50", 35, true, false); + await createTask(page, "线性代数习题 3.2", "线性代数", 30, false, true); + + await expect(page).toHaveScreenshot("plan-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("link", { name: "Today" }).click(); + await expect(page).toHaveScreenshot("today-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.getByRole("link", { name: "Plan" }).click(); + await page.getByRole("article", { name: /高数:复习微分中值定理/ }).getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("本次目标(可选)").fill("完成本章定理复习与两道例题"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + await page.clock.runFor(8 * 60 * 1000); + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250)); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("focus-nature-desktop.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("focus-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("button", { name: "返回 StudyFlow" }).click(); + await page.getByRole("link", { name: "Today" }).click(); + await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("today-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + await page.getByRole("link", { name: "Plan" }).click(); + await page.locator(".active-session-bar time").evaluate((element) => { element.textContent = "08:00"; }); + await expect(page).toHaveScreenshot("plan-nature-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); +}); + +test("正式视觉支持键盘焦点与减少动画", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: "今天,按自己的节奏前进" })).toBeVisible(); + await page.keyboard.press("Tab"); + const todayLink = page.getByRole("link", { name: "Today" }); + await expect(todayLink).toBeFocused(); + const transitionSeconds = await todayLink.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration)); + expect(transitionSeconds).toBeLessThanOrEqual(0.001); +}); + +test("Focus 暂停、超时和三按钮状态在手机宽度保持清晰", async ({ page }) => { + await page.clock.install({ time: FIXED_TIME }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await page.getByRole("button", { name: "开始学习" }).click(); + await page.getByLabel("学习名称").fill("移动端番茄视觉检查"); + await page.getByRole("radio", { name: /番茄钟/ }).check(); + await page.getByLabel("本次专注时长").fill("1"); + await page.getByLabel("本次短休息").fill("1"); + await page.getByRole("button", { name: "进入 Focus" }).click(); + + await page.getByRole("button", { name: "暂停" }).click(); + await expect(page.locator(".focus-botanical")).toHaveClass(/paused/); + await page.getByRole("button", { name: "继续" }).click(); + await page.clock.runFor(61_000); + await expect(page.getByText("超时专注 · 正计时")).toBeVisible(); + await expect(page.getByRole("progressbar", { name: "当前专注阶段进度" })).toHaveAttribute("aria-valuenow", "100"); + await expect(page.locator(".focus-botanical")).toHaveClass(/overtime/); + + await page.clock.pauseAt(await page.evaluate(() => Date.now() + 250)); + await page.locator(".focus-orbit time").evaluate((element) => { element.textContent = "00:01"; }); + await expectNoButtonOverlap(page); + await expect(page).toHaveScreenshot("focus-overtime-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.getByRole("button", { name: "开始休息" }).click(); + await page.getByRole("button", { name: "暂停" }).click(); + await expect(page.locator(".focus-botanical")).toHaveClass(/paused/); + await expectNoButtonOverlap(page); + await expect(page).toHaveScreenshot("focus-break-paused-mobile.png", { animations: "disabled", maxDiffPixels: 150 }); + + await page.setViewportSize({ width: 320, height: 844 }); + await expectNoButtonOverlap(page); +}); + +async function expectNoButtonOverlap(page: Page) { + const boxes = await page.locator(".focus-actions > .button").evaluateAll((buttons) => buttons.map((button) => { + const box = button.getBoundingClientRect(); + return { + left: box.left, + right: box.right, + top: box.top, + bottom: box.bottom, + contentFits: button.scrollWidth <= button.clientWidth, + }; + })); + expect(boxes.every((box) => box.contentFits)).toBe(true); + for (let first = 0; first < boxes.length; first += 1) { + for (let second = first + 1; second < boxes.length; second += 1) { + const a = boxes[first]; const b = boxes[second]; + const overlaps = a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + expect(overlaps).toBe(false); + } + } +} diff --git a/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png new file mode 100644 index 0000000..7041239 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-break-paused-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png new file mode 100644 index 0000000..618b393 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png new file mode 100644 index 0000000..f7f6733 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-nature-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png new file mode 100644 index 0000000..0745ed2 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/focus-overtime-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png new file mode 100644 index 0000000..abb37ba Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/plan-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png new file mode 100644 index 0000000..839a381 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/plan-nature-mobile-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png b/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png new file mode 100644 index 0000000..67d0a74 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/today-nature-desktop-linux.png differ diff --git a/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png b/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png new file mode 100644 index 0000000..4fef4a5 Binary files /dev/null and b/e2e/visual-system.spec.ts-snapshots/today-nature-mobile-linux.png differ diff --git a/src/app/App.tsx b/src/app/App.tsx index 61f7d3e..2b57c02 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, type ChangeEvent, type ReactNode } from "react"; -import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Play, Settings, Tags } from "lucide-react"; +import { BookOpen, CalendarCheck, Database, History, LayoutGrid, Leaf, Play, Settings, Tags } from "lucide-react"; import type { Category, CreateTaskInput, Task } from "../domain/models"; import type { ExecutionSettings, FinishSessionInput, StartContext, StartSessionInput, StudyInterval, StudySession, TimerMode } from "../features/executionTypes"; import type { PomodoroSettingsSnapshot } from "../../shared/schemas/models"; @@ -53,7 +53,7 @@ export default function App(){ -
{error&&
+
{error&&
{error}
}{notice&&
diff --git a/src/assets/nature/focus-forest.webp b/src/assets/nature/focus-forest.webp new file mode 100644 index 0000000..0e5290a Binary files /dev/null and b/src/assets/nature/focus-forest.webp differ diff --git a/src/assets/nature/meditation-water.webp b/src/assets/nature/meditation-water.webp new file mode 100644 index 0000000..361062f Binary files /dev/null and b/src/assets/nature/meditation-water.webp differ diff --git a/src/components/PlantIllustration.tsx b/src/components/PlantIllustration.tsx new file mode 100644 index 0000000..8135169 --- /dev/null +++ b/src/components/PlantIllustration.tsx @@ -0,0 +1,55 @@ +type PlantKind = "tree" | "flower"; + +export function PlantIllustration({ kind, stage, variant = 0, overtime = false }: { + kind: PlantKind; + stage: 0 | 1 | 2 | 3 | 4; + variant?: number; + overtime?: boolean; +}) { + const hueClass = `plant-variant-${variant % 3}`; + if (kind === "flower") { + return + + + + + + + + {stage === 0 && } + {stage >= 1 && 2 ? 128 : 145} ${225 - stage * 27} 140 ${205 - stage * 28}`} stroke="url(#flower-stem)"/>} + {stage >= 1 && } + {stage >= 2 && } + {stage >= 3 && + {stage === 3 ? : <> + + {[0,60,120,180,240,300].map(angle => ) } + + } + } + {overtime && stage === 4 && } + ; + } + + return + + + + + + + + {stage === 0 && } + {stage >= 1 && = 4 ? 15 : stage >= 3 ? 11 : 7} d={`M160 274 Q${stage >= 3 ? 150 : 164} ${233 - stage * 16} 160 ${210 - stage * 31}`} />} + {stage >= 2 && } + {stage >= 3 && } + {stage >= 4 && } + {stage >= 1 && + = 3 ? 34 : 22} ry={stage >= 3 ? 27 : 18}/> + {stage >= 2 && } + {stage >= 3 && <>} + {stage >= 4 && <>} + } + {overtime && stage === 4 && <>} + ; +} diff --git a/src/design-preview/DesignPreviewApp.tsx b/src/design-preview/DesignPreviewApp.tsx new file mode 100644 index 0000000..2d2cf4a --- /dev/null +++ b/src/design-preview/DesignPreviewApp.tsx @@ -0,0 +1,156 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { BookOpen, CalendarDays, Check, ChevronRight, Clock3, Flower2, History, LayoutGrid, Leaf, Monitor, MoreHorizontal, Pause, Play, Plus, RotateCcw, Settings, Smartphone, Sparkles, Sprout, Timer, TreePine } from "lucide-react"; +import focusForest from "../assets/nature/focus-forest.webp"; +import meditationWater from "../assets/nature/meditation-water.webp"; +import { PlantIllustration } from "../components/PlantIllustration"; +import "./design-preview.css"; + +type Screen = "today" | "plan" | "focus" | "meditation" | "growth"; +type Device = "desktop" | "mobile"; +const screens: { id: Screen; label: string }[] = [ + { id: "today", label: "Today" }, { id: "plan", label: "Plan" }, { id: "focus", label: "Focus" }, + { id: "meditation", label: "Meditation" }, { id: "growth", label: "成长阶段" }, +]; + +const mockTasks = [ + { title: "高数:复习微分中值定理", category: "高数", time: "45 分钟", urgent: true, done: false }, + { title: "CS50 Week 4 笔记整理", category: "CS50", time: "35 分钟", urgent: false, done: false }, + { title: "线性代数习题 3.2", category: "线性代数", time: "30 分钟", urgent: false, done: true }, +]; + +export function DesignPreviewApp() { + const params = useMemo(() => new URLSearchParams(window.location.search), []); + const initial = params.get("screen") as Screen | null; + const [screen, setScreen] = useState(screens.some(item => item.id === initial) ? initial! : "today"); + const [device, setDevice] = useState(params.get("device") === "mobile" ? "mobile" : "desktop"); + + useEffect(() => { + const next = new URL(window.location.href); + next.searchParams.set("design-preview", "1"); next.searchParams.set("screen", screen); next.searchParams.set("device", device); + window.history.replaceState(null, "", next); + }, [screen, device]); + + return
+
+
StudyFlowDesign preview · 假数据
+ +
+ + +
+
+
+
+ {screen === "today" && } + {screen === "plan" && } + {screen === "focus" && } + {screen === "meditation" && } + {screen === "growth" && } +
+
+
; +} + +function AppFrame({ active, children, onNavigate }: { active: Screen; children: ReactNode; onNavigate: (page: Screen) => void }) { + const nav = [ + ["today", , "Today"], ["plan", , "Plan"], ["focus", , "Focus"], + ["meditation", , "Meditation"], ["growth", , "成长"], + ] as const; + return
+ +
{children}
+ +
; +} + +function TodayPreview({ onNavigate }: { onNavigate: (page: Screen) => void }) { + return +
+ +
+
+
今日计划

2 小时 30 分

稳定推进
+
+
+
已完成45 分
剩余1 小时 45 分
+
实际专注1 小时 11 分
完成任务1 / 3
+
+ +
+
+
今日花园

专注留下的痕迹

+
+
+
+
+
+
+

今天已种下 3 株植物 · 其中 1 次冥想

+
+
+

今日任务

截止今天与已经逾期的任务

+
{mockTasks.map((task,index) => )}
+
+
+
; +} + +function PlanPreview({ onNavigate }: { onNavigate: (page: Screen) => void }) { + const quadrants = [ + ["重要且紧急", "优先处理", "urgent", mockTasks.slice(0,1)], ["重要但不紧急", "持续投入", "important", mockTasks.slice(1,2)], + ["不重要但紧急", "尽快安排", "prompt", [{...mockTasks[2], done:false}]], ["不重要且不紧急", "有余力再做", "later", []], + ] as const; + return
+ +
+
{quadrants.map(([title, hint, tone, tasks]) =>

{title}

{hint} · {tasks.length}
{tasks.map((task,index)=>)}{tasks.length===0&&
这里暂时很安静
}
)}
+
; +} + +function FocusPreview() { + const [state,setState]=useState<"running"|"paused"|"overtime">("running"); + const [progress,setProgress]=useState(72); + const stage = progress < 10 ? 0 : progress < 35 ? 1 : progress < 65 ? 2 : progress < 100 ? 3 : 4; + return
+
StudyFlow · Focus
+
+
+
+
{state==="overtime"?"本轮已完成 · 超时专注":state==="paused"?"已暂停 · 植物也在休息":"第 2 轮 · 正在生长"}高数
+

复习微分中值定理

{state==="overtime"?"+ 06:18":"18:42"}
+

目标 25 分钟 · 已生长 {Math.min(progress,100)}%

+
+
+
+
交互状态
+
; +} + +function MeditationPreview() { + const [phase,setPhase]=useState<"setup"|"breathing"|"timer"|"review">("setup"); + const [breath,setBreath]=useState("4-7-8"); const [duration,setDuration]=useState(10); const [paused,setPaused]=useState(false); + return
+
StudyFlow · Meditation
+ {phase==="setup"&&
给自己一点空间

此刻,你需要怎样的停留?

冥想不是要清空思绪,而是温柔地看见它们。

{[5,10,15,20,30].map(value=>)}
{[["4-7-8","4-7-8","4 轮"],["4-4","均衡呼吸","8 轮"],["box","箱式呼吸","4 轮"],["none","不使用引导",""]].map(([id,name,rounds])=>)}
} + {phase==="breathing"&&
吸气4

让气息自然进入,不必用力。

} + {phase==="timer"&&
{paused?"已暂停":"安静地停留"}{duration===0?"12:08":"08:36"}{duration===0?"自由冥想":"剩余时间"}
} + {phase==="review"&&
这次停留已经被好好保存

现在感觉如何?