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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) · versi

> 时间戳精确到分钟(`YYYY-MM-DD HH:MM`,北京时间 UTC+8)。同一天多次改动按时间倒序——最新在最上。每段下面扁平列表,前缀标类型(`add` / `change` / `fix` / `test` / `chore`)。

### 2026-08-23 20:55

- **change(web)**:将 3237 行 `App.css` 按原始级联顺序机械拆为 8 个样式文件,单文件最大 495 物理行;入口文件只保留有序导入
- **add(quality)**:新增 CSS 500 物理行门禁并接入前端 lint,递归覆盖 `web/src/**/*.css`,同时补行数计算和超限识别测试
- **chore(docs)**:贡献指南补充 CSS 行数、导入顺序和构建验证约束,持续交接文档同步当前处理状态

### 2026-08-23 20:39

- **fix(history)**:历史详情和公开分享页的已作答场景视频不再挤进 `80×80` 缩略框,改为内容区全宽 `16:9` 播放器;静态图片继续使用紧凑缩略图
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ git checkout master && git pull

- **页面的关键状态必须可被 URL 还原**:进入一个新视图 / 子状态(如练习的"结果 / 反馈页")时,URL 要跟着变(path 段或 query param),且刷新后能从 URL + 后端数据重建该状态——绝不能"刷新就回到初始态、结果没了"。数据已落库的(如 attempt)刷新时从库里重建,不要只存内存。
- **测试要是代码**:不靠 curl 一次性脚本。
- **CSS 文件不超过 500 物理行**:`pnpm run lint:styles` 递归检查 `web/src/**/*.css`,并已接入 `pnpm run lint`。`App.css` 只负责按顺序导入 `styles/app/*.css`,后面的文件允许有意覆盖前面的选择器,拆分或重排时必须保持级联顺序并运行生产构建。
- **批量调 LLM / 文生图很贵**:默认一次 ≤5 个;先 `--dry-run` 验证文案,再花生图钱。详见 `docs/design/场景练习/scenario-taxonomy.md`。
- **文档随代码一起改**:`docs/业务/*.md` 记录**当前已实现**行为(一个模块一篇),任何对外行为变更必须在同一次 PR 里同步更新对应业务文档(没有就新建一篇)+ `CHANGELOG.md`;数据模型变更同步 `docs/design/schema.md`。设计稿(`docs/design/`)落地后回写进展。重要改动另在 `docs/changelog/<时间戳>-<名字>/` 追加一条详细记录(背景/权衡/验证,只增不改)。文档地图见 `docs/README.md`。

Expand Down
47 changes: 25 additions & 22 deletions docs/SpeakUp-需求与进度交接-2026-08-23.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
## 一、当前基线

- 仓库:`/Users/mint/workspace/learning/speakup`
- 当前分支:`fix-history-video-layout`
- 当前分支基于 `master` 提交 `1adb149` 创建;分支最新提交以 `git rev-parse HEAD` 为准。
- 当前分支:`refactor-split-web-styles`
- 当前分支基于已上线的 `master` 提交 `12e393c` 创建;分支最新提交以 `git rev-parse HEAD` 为准。
- 已上线功能 PR:[#164](https://github.com/makewheels/speakup/pull/164)
- 部署版本探针修复 PR:[#165](https://github.com/makewheels/speakup/pull/165)
- 线上版本此前已通过 `/api/version` 核对为当前 `master`,健康接口正常。
- 工作区有一份用户自己的未跟踪文档:`docs/multica-线上开发环境.md`。不要修改、暂存或删除。
- 本交接文档是当前任务新增的持久文档;History 视频修复已提交并创建 [PR #166](https://github.com/makewheels/speakup/pull/166),待 CI、合并、部署和线上验证。
- 当前任务修改文件:视频布局与回归测试、`CHANGELOG.md`、`docs/README.md`、`docs/业务/4-历史与分享.md` 和本文档;提交前以 `git status --short` 为准。
- 本交接文档是当前任务新增的持久文档;History 视频修复 [PR #166](https://github.com/makewheels/speakup/pull/166) 已合并、由流水线部署并完成线上版本核验,功能通知工作流已成功发送。
- 当前任务正在处理 CSS 拆分与 500 行门禁;提交前以 `git status --short` 为准。

## 二、需求总表

Expand All @@ -25,12 +25,12 @@
| 5 | 差距卡与追问输入区重做 | 已完成并上线 | 类型与标题合并;核心三行固定为「你说 / 这样说 / 为什么」;例句折叠;移除 Meaning;追问区加宽。 |
| 6 | 功能完成邮件通知 | 已完成基础设施 | 工作流、HTML/纯文本邮件和 Infisical OIDC 已上线;收件配置已纠正并完成无明文读回校验。 |
| 7 | 发音、重音和近音词纠正方案调研 | 调研完成,未接入 | 见 `docs/评测/发音纠正选型调研.md`;未购买、未部署、未改产品链路。 |
| 8 | `App.css` 超长及 500 行门禁 | 待处理 | 已审计,必须拆分并补 CSS/物理行数门禁。 |
| 8 | `App.css` 超长及 500 行门禁 | **PR #167,待合并与上线** | 已按原级联顺序拆为 8 个文件,最大 495 行;CSS 物理行数检查已接入 lint,282 项测试通过。 |
| 9 | 网站首次打开请求地理位置 | 待处理 | 已确认只为按日出日落切换主题;业务不需要,应移除。 |
| 10 | 产品埋点、用户轨迹和统一 `trace_id` | 调研完成,待产品决定 | 当前没有完整产品埋点;尚未部署任何新系统。 |
| 11 | History/历史详情视频播放器太小、缩在左上角 | **PR #166,待合并与上线** | 视频已移出固定 `80×80` 的 hero 缩略框;自动化验证已通过,真实页面视觉检查仍待补。 |
| 11 | History/历史详情视频播放器太小、缩在左上角 | **已完成并上线** | PR #166 已合并;线上版本为 `12e393c`,健康接口和生产资源均已核验。真实设备视觉检查仍待补。 |

## 三、History 视频播放器:本地修复已完成
## 三、History 视频播放器:已上线

### 现象

Expand Down Expand Up @@ -66,23 +66,25 @@
- 浅色/深色均检查;播放、暂停、进度条和全屏按钮可用。
- 运行 `pnpm run lint`、相关测试、全量前端测试和 `pnpm run build`。
- 当前会话的浏览器控制能力未暴露,尚未完成真实页面视觉复现;后续执行者若有浏览器能力,必须补做视觉检查,不能只凭测试宣称 UI 已验证。
- 已完成:本地修改、提交、推送和 PR #166。
- 尚未完成:CI、合并、生产部署、线上烟测和真实页面视觉检查。
- 已完成:本地修改、测试、PR #166、CI、合并、自动部署、线上版本/健康/静态资源核验和功能邮件通知。
- 尚未完成:当前环境缺少浏览器控制入口,因此真实页面与手机宽度视觉检查未完成。

## 四、CSS 拆分与 500 行门禁

### 已确认事实
### 原始问题与当前状态

- `web/src/App.css` 当前仍有 **3200 多行**(本分支现为 3237 行)。
- 原 `web/src/App.css` 有 3237 行;现已变为有序导入入口,原内容机械拆到 `web/src/styles/app/` 的 8 个文件中。
- 8 个文件依次为 479、417、366、355、444、495、477、204 行,均不超过 500 物理行。
- 机械拆分完成时,文件按顺序拼接后的 SHA-256 与原 `App.css` 完全一致;随后只中性化了一条旧注释,CSS 规则和顺序未变,Vite 构建产出的 CSS 资源文件名也保持不变。
- `web/eslint.config.js` 只匹配 `**/*.{js,jsx}`;CSS 完全不受 ESLint 管理。
- `max-lines` 虽配置为 500,但跳过空行和注释,并非严格物理行数。
- `pnpm exec eslint src/App.css` 只提示没有匹配配置,退出码仍为 0。
- 已新增 `web/scripts/check-style-lines.js`,对 `web/src/**/*.css` 执行严格物理行数检查,并通过 `pnpm run lint` 强制执行。
- `PracticePage.jsx` 物理 576 行,但按现规则有效行 496,因此仍通过。
- 如果采用“所有代码文件物理行数不超过 500”的严格口径,还要处理数个超长测试文件,不能只拆 CSS。
- 本次门禁专门覆盖 CSS;如果采用“所有 JS/JSX 也按物理行不超过 500”的更严格口径,还需单独拆分 `PracticePage.jsx` 和两个测试文件,不能伪装成已经完成。

### 安全拆分顺序
### 已实施拆分顺序

第一步只做机械切片,保持原始级联顺序,不同时删除旧样式。建议由一个 `styles/index.css` 按顺序导入:
本次只做机械切片,保持原始级联顺序,不同时删除旧样式。由 `App.css` 按顺序导入:

- tokens/theme
- shell/primitives
Expand All @@ -101,17 +103,18 @@
- free practice
- 现有 `practice-preferences.css` 继续最后加载

每个文件必须小于 500 物理行。重点保护 `.detail-hero`、`.chip`、`.su-btn`、`.active`、`.loading` 等依赖后写覆盖的选择器。
每个文件必须不超过 500 物理行。重点保护 `.detail-hero`、`.chip`、`.su-btn`、`.active`、`.loading` 等依赖后写覆盖的选择器。

第二步再通过浏览器 CSS Coverage 和 JSX 动态类名审计删除约 490 行疑似旧样式;不要把“拆分”和“删除”放在同一提交。

### 门禁建议
### 已实施门禁与后续

- 新增前端源码物理行数检查,覆盖 `web/src/**/*.{js,jsx,css}`。
- 将检查接入 `pnpm run lint` 与 CI。
- 已新增 CSS 物理行数检查,覆盖 `web/src/**/*.css`。
- 已将检查接入 `pnpm run lint`,因此现有前端 CI 自动执行。
- CSS 语法/规则另接 Stylelint;不要简单把 CSS 后缀加入 ESLint 的 JS 配置。
- 在 `CONTRIBUTING.md` 写清楚本地命令和严格口径。
- 已在 `CONTRIBUTING.md` 写清本地命令、严格口径和级联顺序约束。
- 现有 23 个 warning 尚未清零,不能直接加 `--max-warnings=0` 而不先治理。
- 已提交并创建 [PR #167](https://github.com/makewheels/speakup/pull/167),待 CI、合并、自动部署与线上静态资源核验。

## 五、移除地理位置权限

Expand Down Expand Up @@ -199,8 +202,8 @@

## 十、建议执行顺序

1. 等待 PR #166 的 CI,通过后合并并只由流水线部署;随后核对线上版本并尽可能补真实页面视觉验证。
2. 单独 PR 做 `App.css` 机械拆分和物理 500 行门禁。
1. 完成 CSS 拆分分支的全量前端验证、PR、CI、合并、自动部署和线上资源核验。
2. 如要把 JS/JSX 也改成严格物理 500 行,另开 PR 拆分 3 个既存超长文件。
3. 单独 PR 移除地理位置请求、改为跟随系统主题。
4. 由产品明确选择埋点目标后,再写事件字典和隐私方案;未决定前不部署分析系统。
5. 轮换邮件授权码并更新 Infisical,完成一次明确授权后的真实通知验证。
Expand Down
36 changes: 36 additions & 0 deletions docs/changelog/20260823-2055-前端样式拆分/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 前端样式拆分

- 时间:2026-08-23 20:55(北京)
- 关联 PR:#167

## 背景

前端所有主要样式长期集中在 3237 行的 `App.css` 中。现有 ESLint 只检查 JS/JSX,无法阻止 CSS 文件继续增长,也让修改局部页面样式时难以确认级联影响范围。

## 方案与权衡

- 按现有顶层章节边界把 `App.css` 机械拆成 8 个文件,由原入口按固定顺序导入;
- 不在同一次改动中删除疑似废弃选择器或重新组织规则,优先保证级联顺序不变;
- 每个拆分文件不超过 500 物理行,最大文件为 495 行;
- 新增独立 Node 检查脚本,递归扫描 `web/src/**/*.css`,500 行允许、501 行失败;
- 将检查接入 `pnpm run lint`,复用现有 CI,无需再引入样式工具依赖;
- 新增自动化测试覆盖末尾换行计数、递归扫描、忽略非 CSS 文件和超限识别。

机械切片完成时,8 个文件按顺序拼接的 SHA-256 与原文件完全一致。随后只中性化了一条旧注释,CSS 规则及其顺序没有变化。Vite 构建产出的 CSS 资源文件名也与拆分前一致。

本轮门禁只解决用户指出的 CSS 问题。JS/JSX 继续使用 ESLint 的 500 有效行规则;现有 3 个物理行超过 500 的 JS/JSX 文件如需改成严格物理行口径,应另开 PR,避免把逻辑重构混入纯样式迁移。

## 验证

- CSS 行数检查扫描 11 个文件,全部不超过 500 行;
- 门禁测试 2 项通过;
- 前端全量测试:26 个文件、282 项通过;
- 覆盖率:Statements 88.03%、Branches 81.86%、Functions 86.11%、Lines 91.66%;
- ESLint:0 error,保留 23 个既存 warning;
- Vite 生产构建通过,CSS 资源文件名与拆分前一致;
- `git diff --check` 通过;
- 独立只读复核确认切点均位于顶层规则之间,导入路径和原始级联顺序正确。

## 修复与后续

后续删除旧 CSS 前,应先结合真实页面走查和 CSS Coverage 独立处理,不要把删除与本次机械拆分混为一项。若继续推进全源码严格 500 物理行,需要先拆分既存的超长页面与测试文件,再扩大门禁范围。
1 change: 1 addition & 0 deletions docs/changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

| 目录 | 内容 |
|------|------|
| [20260823-2055-前端样式拆分](20260823-2055-前端样式拆分/README.md) | App.css 机械拆分与 CSS 500 物理行门禁 |
| [20260823-2040-历史视频播放器](20260823-2040-历史视频播放器/README.md) | History 详情与公开分享页的已作答视频改为全宽播放器 |
| [20260823-1952-结果页笔记与独立标准答案](20260823-1952-结果页笔记与独立标准答案/README.md) | 手动笔记、结果分享、隔离标准答案、差距卡、通知与发音调研 |
| [20260819-1014-结果页与自由录入](20260819-1014-结果页与自由录入/README.md) | 结果页打磨(深色配色/gap 字段/改名/自动笔记)+ 自由录入模式 + 文档重构 |
6 changes: 6 additions & 0 deletions web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,10 @@ export default defineConfig([
complexity: ['warn', 15],
},
},
{
files: ['scripts/**/*.js'],
languageOptions: {
globals: globals.node,
},
},
])
3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"lint": "pnpm run lint:styles && eslint .",
"lint:styles": "node scripts/check-style-lines.js",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
Expand Down
69 changes: 69 additions & 0 deletions web/scripts/check-style-lines.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";

export const MAX_STYLE_LINES = 500;

export function countPhysicalLines(source) {
if (!source) return 0;
const lines = source.split(/\r\n|\r|\n/).length;
return /(?:\r\n|\r|\n)$/.test(source) ? lines - 1 : lines;
}

async function listCssFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];

for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
files.push(...await listCssFiles(entryPath));
} else if (entry.isFile() && entry.name.endsWith(".css")) {
files.push(entryPath);
}
}

return files.sort();
}

export async function inspectStyleLines(rootDirectory, limit = MAX_STYLE_LINES) {
const files = await listCssFiles(rootDirectory);
const oversized = [];

for (const file of files) {
const source = await readFile(file, "utf8");
const lines = countPhysicalLines(source);
if (lines > limit) {
oversized.push({
file: path.relative(rootDirectory, file),
lines,
});
}
}

return { files, oversized };
}

async function main() {
const sourceRoot = fileURLToPath(new URL("../src/", import.meta.url));
const { files, oversized } = await inspectStyleLines(sourceRoot);

if (oversized.length > 0) {
console.error(`CSS physical line limit exceeded (max ${MAX_STYLE_LINES}):`);
for (const item of oversized) {
console.error(`- ${item.file}: ${item.lines} lines`);
}
process.exitCode = 1;
return;
}

console.log(`CSS line check passed: ${files.length} files <= ${MAX_STYLE_LINES} physical lines`);
}

const invokedUrl = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: "";

if (import.meta.url === invokedUrl) {
await main();
}
50 changes: 50 additions & 0 deletions web/scripts/check-style-lines.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";

import { countPhysicalLines, inspectStyleLines } from "./check-style-lines.js";

const createdDirectories = [];

async function createTaskTempDirectory() {
const directory = await mkdtemp(path.join(tmpdir(), "speakup-style-lines-"));
createdDirectories.push(directory);
return directory;
}

afterEach(async () => {
const tempRoot = `${path.resolve(tmpdir())}${path.sep}`;
for (const directory of createdDirectories.splice(0)) {
const resolved = path.resolve(directory);
if (!resolved.startsWith(tempRoot) || !path.basename(resolved).startsWith("speakup-style-lines-")) {
throw new Error(`Refusing to clean unexpected path: ${resolved}`);
}
await rm(resolved, { recursive: true, force: true });
}
});

describe("check-style-lines", () => {
it("counts physical lines without inventing a line after the final newline", () => {
expect(countPhysicalLines("")).toBe(0);
expect(countPhysicalLines("a")).toBe(1);
expect(countPhysicalLines("a\n")).toBe(1);
expect(countPhysicalLines("a\n\n")).toBe(2);
expect(countPhysicalLines("a\r\nb")).toBe(2);
});

it("reports only CSS files above the configured limit", async () => {
const root = await createTaskTempDirectory();
await mkdir(path.join(root, "nested"));
await writeFile(path.join(root, "ok.css"), "a {}\n".repeat(3));
await writeFile(path.join(root, "nested", "too-long.css"), "a {}\n".repeat(6));
await writeFile(path.join(root, "ignored.js"), "x\n".repeat(20));

const result = await inspectStyleLines(root, 5);

expect(result.files).toHaveLength(2);
expect(result.oversized).toEqual([
{ file: path.join("nested", "too-long.css"), lines: 6 },
]);
});
});
Loading
Loading