Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ data/_compare_*
data/notegen.db
data/notegen.db-wal
data/notegen.db-shm
data/redis/
# DB 备份(e2e/迁移留存),同样含 bcrypt 哈希,严禁入库
data/*.e2ebak
data/user_notes/
Expand Down
Binary file removed data/redis/appendonlydir/appendonly.aof.1.base.rdb
Binary file not shown.
Binary file removed data/redis/appendonlydir/appendonly.aof.1.incr.aof
Binary file not shown.
2 changes: 0 additions & 2 deletions data/redis/appendonlydir/appendonly.aof.manifest

This file was deleted.

Binary file removed data/redis/dump.rdb
Binary file not shown.
53 changes: 53 additions & 0 deletions docs/frontend-redesign.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,64 @@
**P2 · 打磨** 进行中
- [x] 加载骨架和错误态已收敛到 `WorkspaceSkeleton` / `WorkspaceError`
- [x] 移动端左栏抽屉已实现
- [x] 公开导航不再暴露 `/mm-ablation` 内部实验页入口
- [x] 移动端 `ChatPanel` 回到内容流,避免 sticky 遮挡正文
- [ ] 浏览器 smoke:视频播放、seek、分享、导出、QA 轮询、书签同步
- [ ] a11y:三栏 landmark、章节列表 `aria-current`、Lighthouse a11y ≥ 90
- [ ] 删除确认无引用的旧视觉组件和 `AskBar`
- 验收:`npx tsc --noEmit` 通过;浏览器 smoke 记录写入 `docs/memory/`

## 4.1 2026-06-25 二次升级计划:电影感首页 + 品牌系统 + 沉浸式登录

本轮升级不推翻已经完成的 NotebookLM 式工作台,而是在“入口、品牌、登录注册、关键转场”上增加记忆点。原则是:工作台继续克制高效,品牌表达集中在首页和认证流,避免动效干扰阅读、输入和视频学习。

### A. 首页:电影感滚动入口

- 首页采用电影感首屏:深色/纸面双色基调、大标题、低速镜头感位移、产品片段分层进入。(已完成首版)
- 滚动节奏参考已确认的视频方向,但不复制房地产内容;改成“导入视频 → 时间线解析 → 笔记生成 → 可追问”的学习叙事。(已完成首版)
- 首页允许使用更明显的动效:纸页展开、时间线滑入、播放指针定位、章节卡片错位进入。
- 首页 CTA 分清两个动作:`开始使用` 进入登录/笔记本库,`查看示例` 进入公开演示或示例笔记。
- 移动端不做复杂 scroll takeover,改成一屏一段的线性叙事,保留核心品牌转场。
- `prefers-reduced-motion: reduce` 下关闭镜头位移和连续动画,只保留静态层级与必要状态变化。

### B. Logo:折叠笔记页 + 时间播放指针

- Logo 采用“折叠笔记页 + 时间播放指针”的组合,形成简洁的 `N`。
- 输出三档:完整标志、图形标志、favicon,分别用于导航栏、小尺寸图标和品牌展示。
- 静态状态保持单色、专业、清晰,保证在导航栏和深浅主题下可读。
- 动态状态只用于首页和关键转场:纸页展开、时间线滑入、播放指针定位,形成品牌记忆点。
- 工作台内不高频播放 Logo 动效,避免分散注意力。

### C. 登录 / 注册:独立沉浸式认证界面

- 登录/注册页不使用普通全站导航,改成独立沉浸式界面。(已完成首版)
- 左侧构建品牌角色系统:由纸页、书签、时间戳组成,角色不是拟真人卡通,而是“笔记人格化”的视觉表达。(已完成首版)
- 邮箱输入聚焦时,角色视线跟随输入位置。(已完成)
- 密码输入时,角色遮住眼睛;显示密码时轻微张开一条缝。(已完成)
- 校验失败时,用轻微动作辅助提示,不使用夸张晃动。(已完成)
- 登录成功时,以轻碰、点头或纸页合拢完成庆祝反馈。(已完成组件状态)
- 背景使用书签和时间线结合的柔性飘带,鼠标经过时产生轻微布料波动,作为品牌化空间元素。(已完成首版)
- 注册页保留更亲切的互动反馈,强调“创建第一个笔记空间”的引导感;登录页更简洁、快速、可信。
- 移动端保留一个主角色和核心密码反馈,取消复杂悬停交互。
- 减少动态效果模式下,Logo、角色、飘带全部回落为静态状态。

### D. 工作台:延续克制优化

- 工作台不做电影感 scroll takeover,保持三栏效率模型。
- 修复移动端内容优先级:视频不能长期挤占首屏,笔记内容和提问入口要更早出现。
- 问答入口避免遮挡正文,移动端改成稳定的底部输入或内容流内卡片。
- 章节抽屉补齐焦点管理、`aria-modal` 语义和关闭后的焦点恢复。
- 公开导航不暴露内部实验页,`/mm-ablation` 保留为直接访问或开发入口。(已完成)
- 继续清理 lint、无效 eslint-disable、废弃组件引用和图片加载 warning。

### E. 分批落地顺序

1. **基础修复批次**:计划文档、公开导航收敛、lint 清理、可访问性低风险修复。(已完成首批)
2. **品牌批次**:完善 Logo 三档资产、favicon、导航栏品牌使用规范。(已有 Logo 组件,favicon 待单独验收)
3. **认证批次**:重做登录/注册独立页面,先实现静态布局,再加入角色状态机和密码互动。(已完成首版)
4. **首页批次**:电影感首页和滚动叙事,加入 reduced-motion 回退。(已完成首版)
5. **工作台批次**:移动端排序、问答入口、章节抽屉焦点和浏览器 smoke。

## 5. QA 接口(已落地,2026-06-11)

GPU 串行约束下走异步队列(RQ "qa" 高优队列,插队在排队 pipeline 任务前):
Expand Down
18 changes: 18 additions & 0 deletions web/app/__tests__/landing-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";

import { CINEMATIC_BEATS } from "../landing-model";

describe("landing cinematic model", () => {
it("tells the product story from import to grounded replay", () => {
expect(CINEMATIC_BEATS.map((beat) => beat.id)).toEqual([
"import",
"timeline",
"notes",
"ask",
]);
});

it("keeps every beat tied to a visible time cue", () => {
expect(CINEMATIC_BEATS.every((beat) => beat.timecode && beat.title && beat.copy)).toBe(true);
});
});
11 changes: 8 additions & 3 deletions web/app/bookmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import {
Bookmark as BookmarkIcon, Play, X, Layers, BookOpen, Settings2, Trash2, Plus,
Expand Down Expand Up @@ -235,9 +236,13 @@ export default function BookmarksPage() {
{b.kind === "chunk" && b.keyframeRel ? (
<span className="relative h-12 w-20 shrink-0 overflow-hidden rounded-lg
bg-[var(--bg-muted)]">
<img src={`/notes/${b.noteId}/keyframes/${b.keyframeRel}`} alt=""
className="h-full w-full object-cover dark:brightness-90
transition-transform group-hover:scale-105" />
<Image
src={`/notes/${b.noteId}/keyframes/${b.keyframeRel}`}
alt=""
fill
sizes="80px"
className="object-cover transition-transform group-hover:scale-105 dark:brightness-90"
/>
</span>
) : (
<span className="flex h-12 w-20 shrink-0 items-center justify-center
Expand Down
9 changes: 6 additions & 3 deletions web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -301,16 +301,19 @@ html, body {
font-size: 1rem;
}

.wf-button[data-variant="primary"] {
.wf-button[data-variant="primary"],
.wf-icon-button[data-variant="primary"] {
background: var(--wf-accent);
color: var(--wf-on-accent);
}

.wf-button[data-variant="primary"]:hover:not(:disabled) {
.wf-button[data-variant="primary"]:hover:not(:disabled),
.wf-icon-button[data-variant="primary"]:hover:not(:disabled) {
background: var(--wf-accent-hover);
}

.wf-button[data-variant="primary"]:active:not(:disabled) {
.wf-button[data-variant="primary"]:active:not(:disabled),
.wf-icon-button[data-variant="primary"]:active:not(:disabled) {
background: var(--wf-accent-active);
}

Expand Down
4 changes: 4 additions & 0 deletions web/app/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 26 additions & 0 deletions web/app/landing-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const CINEMATIC_BEATS = [
{
id: "import",
timecode: "00:00",
title: "镜头一:导入视频",
copy: "把课程、讲座或教程拖入 NoteGen,系统先建立来源和时间轴。",
},
{
id: "timeline",
timecode: "03:11",
title: "镜头二:时间线拆分",
copy: "长视频被切成可回看的章节,每个片段都保留原始时间点。",
},
{
id: "notes",
timecode: "08:42",
title: "镜头三:纸页展开",
copy: "章节、重点和术语折叠成一页可复习的结构化笔记。",
},
{
id: "ask",
timecode: "12:06",
title: "镜头四:带引用追问",
copy: "围绕视频继续提问,答案回到对应片段,而不是脱离原始内容。",
},
] as const;
126 changes: 80 additions & 46 deletions web/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
import { Suspense, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, CheckCircle2, LogIn } from "lucide-react";
import { ArrowRight, Eye, EyeOff, LogIn } from "lucide-react";

import { BrandMark } from "@/components/brand/BrandMark";
import NavBar from "@/components/NavBar";
import { Button, Card, Chip, Input } from "@/components/ui";
import { AccountCompanion, type AccountCompanionState } from "@/components/auth/AccountCompanion";
import { Button, IconButton, Input } from "@/components/ui";
import { useAuth } from "@/components/AuthContext";
import { ApiError } from "@/lib/api";

Expand All @@ -18,6 +18,17 @@ function getSafeNextPath(value: string | null) {
return value;
}

type AuthFocus = "email" | "password" | "idle";
type AuthStatus = "idle" | "error" | "success";

function companionState(focus: AuthFocus, passwordVisible: boolean, status: AuthStatus): AccountCompanionState {
if (status === "success") return "success";
if (status === "error") return "error";
if (focus === "email") return "emailFocus";
if (focus === "password") return passwordVisible ? "passwordReveal" : "passwordFocus";
return "idle";
}

function LoginInner() {
const { login } = useAuth();
const router = useRouter();
Expand All @@ -28,15 +39,20 @@ function LoginInner() {
const [err, setErr] = useState<string | null>(null);
const [unverified, setUnverified] = useState(false);
const [busy, setBusy] = useState(false);
const [focus, setFocus] = useState<AuthFocus>("idle");
const [showPassword, setShowPassword] = useState(false);
const [authStatus, setAuthStatus] = useState<AuthStatus>("idle");

async function submit(e: React.FormEvent) {
e.preventDefault();
setErr(null);
setUnverified(false);
setAuthStatus("idle");
setBusy(true);

try {
await login(email.trim(), password);
setAuthStatus("success");
router.push(next);
} catch (e) {
if (e instanceof ApiError && e.status === 403) {
Expand All @@ -45,45 +61,36 @@ function LoginInner() {
} else {
setErr(e instanceof ApiError ? e.message : "登录失败,请确认后端服务是否已启动。");
}
setAuthStatus("error");
setBusy(false);
}
}

const errorId = err ? "login-error" : undefined;
const stageStatus = err ? "error" : authStatus;

return (
<main className="min-h-screen bg-[var(--wf-canvas)] text-[var(--wf-text)]">
<NavBar />
<section className="mx-auto grid max-w-7xl items-center gap-12 px-5 py-14 sm:px-6 md:py-20 lg:grid-cols-[0.95fr_1.05fr]">
<div className="max-w-xl">
<Chip variant="accent" className="gap-2">
<BrandMark size="sm" className="text-[var(--wf-text)]" />
NoteGen Account
</Chip>
<h1 className="mt-6 font-[var(--wf-font-display)] text-5xl font-semibold leading-[1.05] tracking-[-0.03em] text-[var(--wf-text)] md:text-6xl">
回到你的
<br />
视频笔记工作台
</h1>
<p className="mt-6 max-w-lg text-base leading-8 text-[var(--wf-text-secondary)]">
登录后可以继续生成私有笔记、查看任务进度,并把每个视频沉淀成自己的复习资料。
</p>
<div className="mt-8 space-y-3 text-sm text-[var(--wf-text-secondary)]">
{["私有笔记库", "提交历史与任务诊断", "支持链接和本地视频"].map((item) => (
<div key={item} className="flex items-center gap-2">
<CheckCircle2 size={16} className="text-[var(--wf-accent)]" aria-hidden="true" />
<span>{item}</span>
</div>
))}
</div>
</div>
<main className="relative min-h-screen overflow-hidden bg-[var(--wf-canvas)] text-[var(--wf-text)]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-64 bg-[radial-gradient(circle_at_50%_0%,color-mix(in_srgb,var(--wf-brand-coral)_16%,transparent),transparent_62%)]" />
<header className="relative z-10 mx-auto flex h-16 max-w-7xl items-center justify-between px-5 sm:px-6">
<Link href="/" className="inline-flex items-center gap-2 text-[var(--wf-text)]">
<BrandMark variant="full" size="sm" label="NoteGen" />
</Link>
<Link href="/register" className="text-sm font-medium text-[var(--wf-text-secondary)] hover:text-[var(--wf-text)]">
创建账号
</Link>
</header>

<section className="relative z-10 mx-auto grid max-w-7xl items-center gap-8 px-5 pb-16 pt-8 sm:px-6 lg:grid-cols-[1.05fr_0.95fr]">
<AccountCompanion state={companionState(focus, showPassword, stageStatus)} variant="login" />

<Card className="mx-auto w-full max-w-md" padding="lg">
<div className="mx-auto w-full max-w-md rounded-[2rem] border border-[var(--wf-border)] bg-[color-mix(in_srgb,var(--wf-surface)_94%,transparent)] p-6 shadow-[var(--wf-shadow-lg)] backdrop-blur md:p-8">
<div className="mb-7">
<h2 className="font-[var(--wf-font-display)] text-2xl font-semibold tracking-[-0.02em]">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--wf-accent)]">NoteGen Account</p>
<h1 className="mt-3 font-[var(--wf-font-display)] text-3xl font-semibold tracking-[-0.03em]">
登录
</h2>
<p className="mt-2 text-sm text-[var(--wf-text-secondary)]">进入 NoteGen 工作台。</p>
</h1>
<p className="mt-2 text-sm leading-6 text-[var(--wf-text-secondary)]">回到视频笔记工作台,继续整理时间线、书签和问答。</p>
</div>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-2">
Expand All @@ -98,7 +105,13 @@ function LoginInner() {
autoComplete="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(e) => {
setEmail(e.target.value);
setFocus("email");
}}
onClick={() => setFocus("email")}
onFocus={() => setFocus("email")}
onBlur={() => setFocus("idle")}
aria-describedby={errorId}
invalid={Boolean(err)}
/>
Expand All @@ -107,18 +120,39 @@ function LoginInner() {
<label htmlFor="login-password" className="text-sm font-medium text-[var(--wf-text)]">
密码
</label>
<Input
id="login-password"
name="password"
type="password"
required
autoComplete="current-password"
placeholder="输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-describedby={errorId}
invalid={Boolean(err)}
/>
<div className="relative">
<Input
id="login-password"
name="password"
type={showPassword ? "text" : "password"}
required
autoComplete="current-password"
placeholder="输入密码"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setFocus("password");
}}
onClick={() => setFocus("password")}
onFocus={() => setFocus("password")}
onBlur={() => setFocus("idle")}
aria-describedby={errorId}
invalid={Boolean(err)}
className="pr-11"
/>
<IconButton
type="button"
onClick={() => {
setFocus("password");
setShowPassword((v) => !v);
}}
aria-label={showPassword ? "隐藏密码" : "显示密码"}
className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full"
size="sm"
>
{showPassword ? <EyeOff size={15} aria-hidden="true" /> : <Eye size={15} aria-hidden="true" />}
</IconButton>
</div>
</div>
{err ? (
<p id={errorId} className="text-xs leading-5 text-[var(--wf-danger)]" role="alert">
Expand All @@ -140,7 +174,7 @@ function LoginInner() {
注册 <ArrowRight size={12} aria-hidden="true" />
</Link>
</p>
</Card>
</div>
</section>
</main>
);
Expand Down
Loading
Loading