6 Commits
v0.1.3 ... main

Author SHA1 Message Date
7e491d4e60 📚 docs(codestable): 添加 CodeStable 项目知识库
添加 codestable/ 目录,包含项目架构文档、需求文档、
功能设计、问题追踪、参考文档、路线图及工具配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:57:18 +08:00
6bd5c0973f Merge pull request 'feat/optimization-and-audio' (#1) from feat/optimization-and-audio into main
Reviewed-on: #1
2026-04-26 20:51:23 +08:00
a182c64f82 🔖 release(v0.1.4): bump version and UI optimizations
- Remove unused version display logic and update summary
- Add silent audio playback to prevent browser tab throttling
- Update CourseWorkspace and Setting components
- Bump version from 0.1.3 to 0.1.4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 20:45:39 +08:00
0c0d2a0292 feat: add silent audio playback to prevent browser tab throttling during study
Play a nearly inaudible Web Audio API signal when study starts, stop it
when study completes, is stopped, or fails. This prevents browsers from
throttling timers and network requests in background tabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 17:55:27 +08:00
5d4e0f493c chore: add .claude/ to .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 17:39:50 +08:00
a1911573d1 feat: UI optimizations - button feedback, layout fixes, cache clearing, work/exam records
- Add active: state feedback to all buttons across the app
- Fix cache clearing to update Zustand store (not just localStorage)
- Remove checkboxes from settings cache section, compact layout
- Settings page: single outer scroll instead of dual-column scroll
- CourseWorkspace: elastic log panel height, work/exam record counts
- Integrate WorkList/ExamList types and display in UI
- Delete unused CourseList.tsx component
- Fix wk.ts: strict equality, remove unused import

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-26 17:36:02 +08:00
30 changed files with 2334 additions and 621 deletions

3
.gitignore vendored
View File

@@ -23,3 +23,6 @@ dist-ssr
*.sln
*.sw?
doc
# Claude Code
.claude/

View File

@@ -0,0 +1,16 @@
# 刷课系统前端 架构总入口
> 状态:骨架(待填充)
> 创建日期2026-04-25
## 1. 项目简介
刷课系统前端——基于 SolidJS + Vite + Tailwind CSS + Zustand 构建的前端应用,提供账号管理、课程列表、日志查看、系统设置等功能。
## 2. 核心概念 / 术语表
## 3. 子系统 / 模块索引
## 4. 关键架构决定
## 5. 已知约束 / 硬边界

View File

View File

View File

View File

@@ -0,0 +1,97 @@
# 代码维度速查
写代码前先确认每个维度的档位。没明说的走默认,偏离默认的地方要标出来让用户确认。
这份文档是 CodeStable 子技能共享的口径,被 design / fastforward / issue-fix 等阶段引用。项目内的权威副本在 `codestable/reference/code-dimensions.md`,由 `cs-onboard` 从技能包释放。
---
## 核心四维(每次都要定)
### 健壮性 Robustness —— 错误处理的严苛程度
- **L1 快跑**happy path 跑通就行,异常直接崩、让它炸。适合一次性脚本、探索代码。
- **L2 够用**:捕获预期错误(文件不存在、网络超时),非预期错误往上抛。适合内部工具。
- **L3 严防**:所有外部输入验证、所有失败路径都有明确处理、关键操作幂等可重试。适合对外接口、生产系统。
### 结构 Structure —— 代码组织的颗粒度
- **inline**:全写在一起,十几行搞定的那种。
- **functions**:按职责拆函数,同一个文件内。
- **modules**:拆多个文件/模块,有明确的导入关系。
- **layers**:分层架构(如 handler / service / repository有依赖方向约束。
### 性能 Performance —— 对开销的关注度
- **careless**怎么方便怎么写O(n²) 也无所谓。
- **reasonable**:避开明显的坑(循环里查 DB、重复计算但不刻意优化。
- **budgeted**有明确的性能预算延迟、内存、QPS按预算设计数据结构和算法。
- **extreme**:榨性能,要 profiling、要基准测试、可以牺牲可读性。
### 可读性 Readability —— 写给谁看
- **self**:自己当下看得懂就行,命名可以随意。
- **team**:队友半年后还能快速上手,命名规范、关键处有注释。
- **public**:外部开发者能无背景读懂,公共 API 要有文档、示例。
- **teaching**:代码本身就是教材,每一步意图清晰、刻意展示模式。
---
## 场景维度(相关时才定)
### 可演进性 Evolvability —— 预期会怎么变
- **frozen**:接口锁死,不许改(如已发布的库 API
- **stable**:偶尔变,变动要走流程、要兼容。
- **active**:当前在迭代,接口随业务调整。
- **experimental**:随时推倒重来,不考虑向后兼容。
### 可观测性 Observability —— 运行时能看到多少
- **opaque**:黑盒,出了问题靠猜。
- **logged**:关键路径有日志,能事后翻查。
- **traced**:有链路追踪,跨服务能串起来。
- **instrumented**指标齐全metrics / traces / logs 三件套),可接告警。
### 可测试性 Testability —— 测试覆盖的深度
- **untested**:没测试。
- **testable**:结构支持测试(依赖可注入、副作用可隔离),但还没写。
- **tested**:有单元/集成测试覆盖主要路径。
- **verified**:核心逻辑有测试 + 关键不变量有断言/属性测试/形式化验证。
### 安全性 Security —— 信任边界
- **trusted**:全在可信环境内,不设防。
- **validated**:外部输入做校验和清洗。
- **sandboxed**权限最小化、危险操作隔离容器、subprocess 限权)。
- **hardened**:按对抗性环境设计,防注入/防越权/防侧信道,有威胁模型。
---
## 特殊维度(只在涉及时提)
- **Concurrency 并发**single-threaded / thread-safe / lock-free / distributed
- **Determinism 确定性**nondeterministic / reproducible / deterministic
- **Compatibility 兼容性**current-only / backward-compatible / cross-version
- **Idempotency 幂等性**non-idempotent / idempotent / exactly-once
---
## 常用默认组合
| 场景 | 组合 |
|---|---|
| 聊天里问的随手代码 | L1 + inline + careless + self + experimental |
| 项目内部工具 | L2 + functions + reasonable + team + active + logged + testable |
| 对外发布的库/服务 | L3 + modules + budgeted + public + stable + traced + tested + validated |
没明说就按场景走默认。动手前列出关键档位,偏离默认的地方明确标出来让用户确认。
---
## 怎么用这份文档
- **design / fastforward 起草时**AI 先按场景猜默认组合,把判断出的"可能偏离默认"的维度列出来问用户;用户没明确说的维度按默认走。只记偏离项,默认档位不抄。
- **implement / fix 写代码时**:翻一眼当前 feature 或 issue 记录的维度档位,按档位写。比如记了 `健壮性=L3` 就不要偷工省掉输入校验;记了 `可读性=public` 就得补示例和文档。
- **acceptance / review 时**:把维度档位当成验收标准的一部分——档位说 L3 但代码里外部输入没校验,就是不达标。

View File

@@ -0,0 +1,50 @@
# CodeStable 维护者说明
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/maintainer-notes.md`。维护 CodeStable 技能家族时需要反复查阅、但不适合放在各子技能正文里的说明。
---
## 1. 断点恢复
AI 对话随时可能中断token 超限、网络断开、用户换设备)。各阶段发现自己不是从零开始时,必须优先检查已有产物的完成度,从上次停下的地方继续:
- **brainstorm**:如 `{slug}-brainstorm.md` 已有部分内容,读取后问用户"上次聊到 X要接着聊还是推翻重来"
- **design**:如 `{slug}-design.md` 已有部分节,逐节检查完成度,补齐缺失节,不重写已完成节
- **implement**`{slug}-checklist.yaml` 中已 `done` 的步骤不重做,从第一个 `pending` 步骤开始
- **acceptance**:如 `{slug}-acceptance.md` 已有部分节,检查哪些节已填写(有实质 checklist 勾选),从下一个未完成节继续
- **issue-analyze**:如 `{slug}-analysis.md` 已存在,检查 5 节是否都有内容,缺失的补做,已有的不重写
- **issue-fix**:如代码已改但 `{slug}-fix-note.md` 不存在,直接进入验证 + 写 fix-note 环节
恢复时先向用户简短汇报:"检测到上次工作到 X 阶段,我从 Y 继续"。
---
## 2. 扩展点
### 新增子工作流
新工作流定型后,在 `cs-onboard/reference/system-overview.md` 的"技能分成四部分"和"场景路由"表里加一段索引,并登记新的目录位置。
### 跨阶段新约束
如果发现某条规则适用于所有阶段(例如所有 spec doc 都必须补某个字段),优先写进共享 reference`shared-conventions.md``system-overview.md`),不要只改一个子技能。
### 新模板 / 新产物类型
如果引入新的 spec 产物(例如风险评估表、回滚预案),先在 `shared-conventions.md` 登记路径,再在对应阶段技能里引用。
### 共享术语表
如果 CodeStable 自己形成了稳定共享术语,应优先沉淀成共享 reference而不是散落在多个子技能里重复定义。
### 跨工作流状态一览
目前查看"项目当前有几个 feature 在进行中、几个 issue 未关闭"仍需要手动查询。未来如要补 `status.py``codestable/STATUS.md`,先在 `shared-conventions.md` 登记方向,再实现。
---
## 3. 维护规则
- 每次扩展都要同步更新 `system-overview.md` 索引和相关子技能
- 不允许只在某个子技能里加东西而不在 `system-overview.md` 登记
- 共享说明优先放 `codestable/reference/`,不要散落在各子技能里

View File

@@ -0,0 +1,88 @@
---
doc_type: requirement-example
description: 一份好的 requirement doc 长什么样——供 cs-req 起草时参考,也供项目成员扫一眼对齐风格
---
# requirement 文档示例
下面这份示例取自 CodeStable 自己的能力(修 bug 时的探索分析流),用来展示一份好的 requirement doc 的**语气、结构、颗粒度**。新项目做 onboarding 时随包落盘,之后写自己的 requirement 可以直接照着改。
---
## 写作要点速查
- **标题直接平铺**说这能力是什么,不玩比喻、不起花哨名字。
- **用户故事顶在最前面**,每条要能想象出一个具体处境。
- **为什么需要 / 怎么解决 各一段短的**,不上课、不展开。
- **边界用列表**,至少写一条"它不管什么"。
- **不写实现细节**——"通过 X 接口调用 Y 服务"这种挪到 architecture doc。
- **frontmatter 的 `pitch`** 要去技术化、一句话、读者没上下文也能看懂,以后当宣传词用。
---
## 示例正文
````markdown
---
doc_type: requirement
slug: issue-flow
pitch: 修 bug 时先让 AI 探索和分析,再动手改
status: current
last_reviewed: 2026-04-21
implemented_by:
- arch-cs-issue
tags: [debug, ai-assist]
---
# 修 bug 时先探索和分析
## 用户故事
- 作为一个刚接手别人代码的人,我希望把报错直接丢给 AI它告诉我根因在哪而不是自己翻三个文件摸调用链。
- 作为一个被线上问题打断的开发,我希望 AI 帮我收窄嫌疑范围,而不是自己从 git log 一条条比对。
- 作为一个只记得"点那个按钮就白屏"的人,我希望 AI 反过来问我几个问题把现场补清楚,而不是让我自己想该给它什么信息。
## 为什么需要
修 bug 的难点不在改代码,在定位。线索通常零碎(一段报错、一个截图、一句口述),从这点信息摸到真正的根因,往往要先自己耗掉半小时。对不熟的模块、对新接手的人,这段成本更高。
## 怎么解决
先让 AI 读现场——日志、代码、git 历史——交叉验证之后讲清楚"哪里坏了、为什么坏、改动会影响什么"。人确认过再动手改,改完验证。
## 边界
- 不主动扫 bug得你先感知到异常给它入口。
- 线索实在不够时它会反问你补现场,而不是瞎猜。
- 不处理"还没想清楚要做什么"——那是需求 / 设计的事。
````
---
## 反面样例(不要这样写)
这几种写法 AI 很容易默认写出来,都是典型"翻车"
**语气像在上课**
> 本能力旨在通过智能化的探索与分析机制,为开发者提供高效的缺陷定位解决方案……
改成"修 bug 的难点不在改代码,在定位"。
**标题玩比喻**
> 让 AI 当你修 bug 时的第一个读者
改成"修 bug 时先探索和分析"。
**用户故事太抽象**
> 作为用户,我希望系统好用。
删掉。用户故事必须能想象出具体处境。
**把实现细节塞进来**
> 通过调用代码检索服务和 Git 日志分析模块,对报错日志进行上下文推理……
这是 architecture doc 的事,从 requirement 里删掉。

View File

@@ -0,0 +1,286 @@
# CodeStable 共享口径
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/shared-conventions.md`。所有 CodeStable 子技能在运行时用**项目相对路径** `codestable/reference/shared-conventions.md` 引用本文件——这是跨子技能共享但不适合堆在单个技能里的规范的唯一权威版本。
skill 本身不共享文件系统(每个 skill 是独立安装单元),所以共享口径不能放在某个 skill 内部被别的 skill 引用。放在"工作项目"里,对所有 skill 都可达。
---
## 0. 目录结构与路径命名
onboarding 完成后,项目里应当存在如下骨架(`cs-onboard` 负责搭建):
```
codestable/
├── requirements/ 需求中心目录("为什么要有这个能力",只记现状)
│ └── {slug}.md 一个能力一份,扁平(由 cs-req 产出)
├── architecture/ 架构中心目录("用什么结构实现",只记现状)
│ ├── ARCHITECTURE.md 架构总入口(索引 + 关键架构决定)
│ └── {slug}.md 子系统 / 模块架构 doc由 cs-arch 产出)
├── roadmap/ 规划层目录("接下来打算怎么做这块大需求 + 模块怎么切 + 接口怎么定",独立于现状档案)
│ └── {slug}/ 一个大需求一个子目录(由 cs-roadmap 产出)
│ ├── {slug}-roadmap.md 主文档:背景 / 范围 / 模块拆分(概设)/
│ │ 接口契约(架构层详设)/ 子 feature 清单 / 排期思路
│ ├── {slug}-items.yaml 机器可读的子 feature 清单acceptance 回写状态
│ └── drafts/ 可选,草稿 / 调研 / 讨论
├── features/ feature spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每个 feature 一个目录
│ ├── {slug}-brainstorm.md (可选,由 cs-brainstorm 判为 case 2 时产出)
│ ├── {slug}-design.md
│ ├── {slug}-checklist.yaml
│ └── {slug}-acceptance.md
├── issues/ issue spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每个 issue 一个目录
│ ├── {slug}-report.md
│ ├── {slug}-analysis.md (根因不显然时才有)
│ └── {slug}-fix-note.md
├── refactors/ refactor spec 聚合根
│ └── YYYY-MM-DD-{slug}/ 每次 refactor 一个目录
│ ├── {slug}-scan.md
│ ├── {slug}-refactor-design.md
│ ├── {slug}-checklist.yaml
│ └── {slug}-apply-notes.md
├── compound/ 沉淀类文档统一目录
│ └── YYYY-MM-DD-{doc_type}-{slug}.md
│ doc_type ∈ {learning, trick, decision, explore}
├── tools/ 跨工作流共享脚本(由 onboarding 从技能包释放)
└── reference/ 共享参考文档(由 onboarding 从技能包释放,即本文件所在目录)
```
### 命名规则
- 需求文档:`codestable/requirements/{slug}.md`(长效能力清单,不带日期前缀,扁平不分组)
- roadmap 目录:`codestable/roadmap/{slug}/`(一个大需求一个子目录,不带日期前缀,平铺不嵌套)
- feature 目录:`codestable/features/YYYY-MM-DD-{slug}/`,日期用创建当天
- issue 目录:`codestable/issues/YYYY-MM-DD-{slug}/`,日期用报告当天
- refactor 目录:`codestable/refactors/YYYY-MM-DD-{slug}/`,日期用首次扫描当天
- 沉淀类文档:`codestable/compound/YYYY-MM-DD-{doc_type}-{slug}.md`,日期用**归档当天**(不是问题发生当天)
- 架构文档:`codestable/architecture/{type}-{slug}.md`(长效地图,不带日期前缀);总入口始终叫 `ARCHITECTURE.md`
- `AGENTS.md` 在项目根目录,**不在 `codestable/` 里**
### 架构 doc 的分组规则(同类聚合)
`codestable/architecture/` 下的 doc 用文件名**第一段**(首个连字符之前)作为类型标记:`ui-chat.md``ui-events.md` 同属 `ui` 类,`api-routing.md` 自成 `api` 类。所以**所有架构 doc 命名必须遵循 `{type}-{slug}.md`**——只有一份且预计长期独占的,也要带个合理的 type 段(如 `cli-entry.md` 而非 `entry.md`),否则未来同类出现时统计不到、聚合不了。
**触发条件**:某个 type 在 `codestable/architecture/` 根目录下达到或超过 **6 份**文档时(即新加第 6 份的那一次操作),把这一类全部收进同名子目录。
**收入子目录后的命名**:去掉 type 前缀。`ui-chat.md``ui/chat.md``ui-open-files-tree.md``ui/open-files-tree.md`。子目录里不再带 `ui-` 前缀。
**只升不降**:文档因删除回到 ≤5 份也不折回平铺,避免反复改一堆引用。
**触发时谁负责**`cs-arch``backfill` / `update` 模式在 Phase 6 落盘前主动检查;命中阈值时这次操作要把"本次新加 / 改的这份 + 已有同类全部"一起搬迁,并同步改 `ARCHITECTURE.md` 里所有相关链接(搬迁本身要在 Phase 5 一并给用户 review不偷偷做`check` 模式不主动搬迁,但读 `architecture/` 时若发现某 type 已 ≥6 仍平铺,在报告末尾列为观察项交给用户。
### 要改目录结构
`cs-onboard/reference/shared-conventions.md` 这个模板,新项目 onboarding 时会带上新版本。已有项目需要手动同步 `codestable/reference/shared-conventions.md`
---
## 1. 共享元数据口径
### feature spec
- `{slug}-brainstorm.md` / `{slug}-design.md` / `{slug}-acceptance.md` 共用 `doc_type``feature``status``summary``tags` 这组核心字段
- 子技能只补充本阶段特有字段,不重复改写这组字段的含义
- `status` 取值各阶段不同brainstorm = `confirmed`(落盘即确认,无 draftdesign = `draft` / `approved`acceptance 见对应技能
### issue spec
- `{slug}-report.md` / `{slug}-analysis.md` / `{slug}-fix-note.md` 共用 `doc_type``issue``status``tags` 这组核心字段
- `severity``root_cause_type``path` 等属于阶段特有字段,由对应阶段按需补充
### 归档类文档
- `learning` / `trick` / `decision` / `explore` 四个子技能的产物**统一写入 `codestable/compound/` 目录**
- 每个文档必须在 frontmatter 顶部带 `doc_type` 字段(`learning` / `trick` / `decision` / `explore`),作为跨子技能的归属判定
- 文件名统一用 `YYYY-MM-DD-{doc_type}-{slug}.md`——日期打头、`doc_type` 段在中间,`ls` 按名字排序就按归档日期排好;要按类型筛就 grep 中间那段
- 各子技能在 `doc_type` 之外保留自己的专属 frontmatterlearning 的 `track`、trick 的 `type`、decision 的 `category`、explore 的 `type`
- 各子技能只认自己的 `doc_type` 和文件名里的类型段(`YYYY-MM-DD-{doc_type}-...` 中间那段),不读不写别的子技能的文档
- `status` 一类通用字段的语义必须和本文件保持一致,不另起一套口径
- 子技能里如果需要解释状态,只保留该工作流特有的状态流,不重新定义通用语义
### 面向外部读者的文档
- `guidedoc` / `libdoc` 的 frontmatter 由各自子技能定义
- 如无特殊说明:`draft` = 待 review`current` = 当前有效,`outdated` = 代码已变更待同步
### 写作约束
- 子技能提到字段时,优先写"本技能额外字段"或"本阶段状态变化"
- 不要把整套通用字段定义在多个技能里重复展开
---
## 2. {slug}-checklist.yaml 生命周期
- `{slug}-checklist.yaml` 是 feature 工作流的唯一执行清单
-`cs-feat-design``{slug}-design.md` 确认通过后一次生成
- `cs-feat-ff` **不生成** checklist也不写 design doc / acceptance它是跳过 spec 流程、直接让 AI 写代码的超轻量通道,只做动手前的知识检索引导
### design 的职责
- 只负责从方案里提取 `steps``checks`
- 不预先把任何条目标成完成
### implement 的职责
- 只更新 `steps[].status`
- 状态流:`pending``done`
- 不改写 `checks` 的所有权和来源
### acceptance 的职责
- 只更新 `checks[].status`
- 状态流:`pending``passed` / `failed`
- 不回头重写 `steps`
### 写作约束
- 子技能描述 `{slug}-checklist.yaml` 时,只补充本阶段具体要读/写哪一部分
- 不重新定义整份文件的生命周期
---
## 2.5 roadmap ↔ feature 衔接协议
`codestable/roadmap/{slug}/{slug}-items.yaml` 是"规划层"和"feature 执行层"之间的唯一接口。三个技能共同读写它——**不算跨 skill 耦合**,是 skill 都读写项目共享产物,和都读写 `codestable/features/` 同理。
### items.yaml 的状态机
```
planned → in-progress cs-feat-design 启动 feature 时改)
in-progress → done cs-feat-accept 验收完成时改)
planned → dropped cs-roadmap update 模式,用户决定不做时改)
```
`done``dropped` 是终态。需要回退重做的要新加一条 slug 略改的条目,不要改终态。
### cs-roadmap 的职责
- 生成和维护 `{slug}-roadmap.md` 主文档和 `{slug}-items.yaml` 的结构
-`planned` 条目改 `dropped`(用户决定放弃时)
- 不改 `in-progress` / `done` 状态——那两类跃迁由 feature 技能负责
### cs-feat-design 的职责
从 roadmap 条目起头 feature 时:
1.`{slug}-design.md` frontmatter 加两个字段:`roadmap: {roadmap-slug}` + `roadmap_item: {子 feature slug}`
2. 打开 `codestable/roadmap/{roadmap-slug}/{roadmap-slug}-items.yaml`,把对应条目 `status` 改为 `in-progress``feature` 填为 feature 目录名(`YYYY-MM-DD-{slug}`
3. 校验 yaml 语法
直接起 feature不从 roadmap 来)时两个字段留空或省略,不触发任何 roadmap 写操作。
### cs-feat-accept 的职责
验收流程走到收尾时:
1.`{slug}-design.md` frontmatter 的 `roadmap` / `roadmap_item` 字段
2. 字段为空 → 跳过 roadmap 回写
3. 字段有值 → 打开 `codestable/roadmap/{roadmap}/{roadmap}-items.yaml`,把 `roadmap_item` 对应条目 `status` 改为 `done`
4. 同步主文档 `{roadmap}-roadmap.md` 子 feature 清单里对应行的显示状态(保持两份一致)
5. 校验 yaml 语法
回写是**实际写文件的动作**,不是自评"应该不需要改"。验收报告里要明确记录回写结果。
### 最小闭环标记
items.yaml 每份里只有一条 `minimal_loop: true`,标记"这条做完后系统能端到端跑通最窄路径"。feature-design 启动 `minimal_loop: true` 条目时优先级最高——它是整个大需求能不能落地的早期信号。
---
## 3. 阶段收尾推荐
### feature-acceptance
收尾时按顺序判断是否要推荐:
1. `cs-learn`:沉淀经验
2. `cs-decide`:记录长期约束/选型
3. `cs-guide`:更新开发者/用户指南
4. `cs-libdoc`:更新公开 API 参考
5. `scoped-commit`
### issue-fix
收尾时按顺序判断是否要推荐:
1. `cs-learn`:记录坑点
2. `cs-decide`:如修复暴露出长期约束
3. `scoped-commit`
### 推荐动作的统一规则
- 一律一句话提示
- 用户说"不用"立刻跳过
- 推荐不是强制,不得把用户拖入新的工作流
- 上游技能负责主动提示,下游技能负责承接执行
- 不要出现下游说"应该由上游推荐"、上游却没有动作的漂移
---
## 4. 收尾提交scoped-commit
feature-acceptance 和 issue-fix 走完后要把本次产物提交为一个 commit。规则
- **提交范围**:本次工作改到的代码 + 相关 spec 文档 + 本次实际更新过的架构 doc + 本次实际更新过的 roadmap items.yaml / 主文档
- **不该进这个 commit**:和本次工作无关的顺手修改;属于"下次另起一个 feature / issue"的扩大范围
- **提交前确认**:用户没明确同意就不要 `git commit`
- **commit message**:一句话说清楚"这次做了什么",不要把 spec 目录路径贴进 message
子技能只描述本阶段的特有提交范围(比如 acceptance 要带架构 doc通用规则看这里。
---
## 5. 归档检索规则
feature-design / issue-analyze / issue-fix 在动手前要到 `codestable/compound/` 里搜已有的沉淀:
- 总是先搜 `architecture/``compound/` 两个目录
-`compound/` 里用 `doc_type` 字段按需过滤learning / trick / decision / explore
- 搜到的结果只作为参考输入,不盲目套用——可能已过期(`status=outdated`)或不适合当前上下文
- 搜到和当前方向冲突的 decision → 必须在方案 / 分析里正面回应"为什么仍然要这么做"或调整方向
子技能只补充本阶段的具体查询命令。完整搜索语法看 `codestable/reference/tools.md`
---
## 6. 归档类子技能共享守护规则
`cs-learn` / `cs-trick` / `cs-decide` / `cs-explore` 四个子技能共享下面这组规则。各子技能的正文只写本技能特有反模式,通用规则看这里:
1. **只增不删**——已归档的文档除非被明确取代(`status=superseded`),否则不删除;理由丢失的成本极高
2. **宁缺毋滥**——用户说不出理由的节直接省略,不要 AI 编造听起来合理的内容
3. **不替用户写实质内容**——AI 负责起草结构和串联语言,实质结论必须来自用户或可追溯的代码证据
4. **可发现性检查**——写完后检查 `AGENTS.md` / `CLAUDE.md` 里有没有指引 AI 查阅 `codestable/compound/`,没有就**提示**用户(不替用户改)
5. **起草前先查重叠,而不是归档后**——动手写之前就用 `search-yaml.py --query` 查语义相近的旧文档。有命中就把候选列给用户,让用户在三条路径里选一条:
- **更新已有条目**(默认优先):沿用原文件名和原创建日期,**不新建文件**;修改正文相关节,在 frontmatter 补 `updated: YYYY-MM-DD`(归档当天);变更超出小修的话在文末加一段"YYYY-MM-DD 更新"简述改了什么
- **supersede 已有条目**:旧文档保留原文,把 `status` 改成 `superseded`,加 `superseded-by: {新文档文件名}`,正文顶部加一行 `**[已取代]** 见 {新文档 slug}`然后新建文档frontmatter 带 `supersedes: {旧文档文件名}`
- **确实是不同主题**:直接新建,在新文档末尾 `相关文档` 节列出已有那条,说明区别
6. **识别用户意图是"改已有"还是"记新的"**——用户说"改 / 更新 / 修订 / 补充 {某条}"、明确指向某条旧文档、或话题高度重合时,默认走"更新已有条目"路径,不要闷头新建。分不清就问一句,不要猜。
各子技能只认自己的 `doc_type`,不读写别家产物。
---
## 7. 写代码时的反射检查
`cs-feat-impl``cs-issue-fix` 共用的一组代码质量反射检查。AI 默认会往"大函数 / 大文件 / god class / 处处特殊分支"这些方向漂,这一节的目的是把漂移截在发生的那一刻。
**不是阈值,是触发器**。不是"超过 N 行必须拆"——硬数字会诱发为拆而拆,把自然聚合的代码切碎。这里每一条都是"遇到 X 情况就停下来问自己"的反射动作。
| 触发场景 | 停下来问自己 |
|---|---|
| 要往一个已经很长的文件里追加代码时 | 这文件现在承担了几件事?新加的东西是已有职责的延伸,还是第 N+1 件事?是第 N+1 件就默认新建文件 |
| 要给一个已经很多方法的类加方法时 | 新方法是这个类核心职责的自然扩展,还是把这个类推向"什么都能干" |
| 写的函数已经超过一屏时 | 这函数在做几件事?几件事就拆 |
| 要加一个 `if (特殊情况) { 特殊处理 }` 分支时 | 是不是抽象维度选错了?正确的做法可能是把特殊路径和通用路径分成不同的函数 / 策略 / 类,而不是往现有代码里打补丁 |
| 要 copy-paste 一段代码时 | 这段代码能抽成共用的,还是只是碰巧字面相似?能抽就抽 |
| 要给一个函数加第 4+ 个参数时 | 这个函数在做的事情是不是太多了?参数列表是 API 恶化的早期信号 |
| 要新写一个"万能工具类 / helper"时 | 这个东西真的没有归属吗?还是只是因为一时想不起来放哪儿,就先堆在 util 里? |
### 停下来之后
反射检查**只负责把问题提出来**,结论用户定。如果停下来想清楚后的动作(拆文件 / 新建文件 / 重命名 / 抽共用层)会让这次改动超出 `{slug}-checklist.yaml` 里现有步骤的范围,跟用户对齐再决定——要么纳入当前 feature / fix 的推进计划,要么记成顺手发现留到后续。
不许偷偷拆完继续写,也不许忽略信号硬冲。默认动作是停、问、再继续。

View File

@@ -0,0 +1,112 @@
# CodeStable 体系总览
本文档介绍 CodeStable 工作流家族整体——有哪些子技能、各管什么场景、产物怎么组织。无论是 AI 在运行时读到这个文件,还是人打开来看,都能对整个体系有个完整印象。
AI 辅助开发里,有几类场景会反复出现——加新功能、修 bug、遇到值得沉淀的经验、做技术选型、摸新模块的代码、接入新仓库。每种场景如果每次从零处理都会出各自的典型问题AI 给功能起的术语跟老代码冲突、bug 改完没人记得当时怎么诊断的、上周刚踩过的坑下周又踩一遍。
CodeStable 把这几类场景各配一套子技能,产物放进统一的目录结构、带统一的 YAML frontmatter,互相之间可以检索引用。
## 技能分成四部分
**根入口**——开放式诉求 / 不知道走哪个时的统一入口:
- `cs` — 介绍体系全貌 + 把诉求路由到正确的 cs-* 子技能。本技能不做事,只做分诊和提示
**做事**——从一段模糊想法走到上线的功能、或者从一份错误报告走到修好的 bug:
- `cs-feat` — 新功能,design → implement → acceptance想法还模糊时先走讨论层 `cs-brainstorm` 做分诊,不属于 feature 流程内部)
- `cs-issue` — 修 bug,report → analyze → fix
- `cs-refactor` — 代码优化(行为不变、结构/性能/可读性变),scan → design → apply
两类都不直接让 AI 写代码,而是先产出 spec(功能方案 / 问题分析),用户 review 后再动手,代码和 doc 一起交付。针对的是术语冲突、范围失控、改完不留存档这三种 AI 默认会出的问题。
**沉淀**——把做事过程产生的知识存下来,下次遇到同类问题直接复用:
- `cs-learn` — 回顾"做 X 时踩了 Y 这个坑"
- `cs-trick` — 处方"以后做 X 就这样做"
- `cs-decide` — 规定"全项目今后都按 X 来"
- `cs-explore` — 存档"调查了 X 问题,看到代码里是这样的"
**讨论层**——想法还模糊时的统一入口,不直接产出设计或代码:
- `cs-brainstorm` — 和用户对话做分诊:case 1(已经够清楚,直接 feature-design)、case 2(小需求,在 feature 里继续讨论并落 `{slug}-brainstorm.md`)、case 3(大需求,移交给 roadmap)
**辅助**——围着前几类转的周边工具:
- `cs-onboard` — 把新仓库接入 CodeStable 目录结构
- `cs-req` — 起草或刷新 `codestable/requirements/` 下的需求文档("为什么要有这个能力",只记现状)
- `cs-arch` — 架构相关一站式:起草新架构文档 / 刷新已有文档 / 做架构体检(含 design 自洽 / design↔代码一致 / architecture 目录多份文档间一致)。architecture 只记现状
- `cs-roadmap` — 把一块装不进单个 feature 的大需求拆成带依赖和状态的子 feature 清单,作为后续多次 feature 流程的种子和排期依据;独立于需求 / 架构档案
- `cs-guide` — 写给外部读者的开发者指南 / 用户指南
- `cs-libdoc` — 为库的公开 API 逐条目生成参考文档
## 场景路由
仓库里还没有 `codestable/` 目录,先用 `cs-onboard` 搭骨架。
| 场景 | 子技能 |
|---|---|
| 想法还模糊 / "有个想法没想清楚" / "先聊聊" | `cs-brainstorm`(分诊后路由到 design / feature-brainstorm 落盘 / roadmap) |
| 新功能 / 新能力 | `cs-feat` |
| BUG / 异常 / 文档错误 | `cs-issue` |
| 代码优化 / 重构 / 重写(行为不变) | `cs-refactor` |
| 摸代码、提问调研 | `cs-explore` |
| 补 / 更新需求文档 | `cs-req` |
| 补 / 更新 / 检查架构文档 | `cs-arch` |
| 大需求拆解 / 排期规划 | `cs-roadmap` |
| 技术选型 / 约束 / 规约 | `cs-decide` |
| 踩坑回顾、经验总结 | `cs-learn` |
| 可复用的编程模式、库用法 | `cs-trick` |
| 开发者指南 / 用户指南 | `cs-guide` |
| 库 API 参考 | `cs-libdoc` |
完整的操作手册、退出条件、和其他工作流的关系,各子技能里讲。
## 沉淀类四个子技能如何区分
learning / trick / decision / explore 都是存档文档类型,区别在记录内容的性质:
- 回顾某次做 X 时发现了 Y —— `cs-learn`(产出 `doc_type: learning`)
- 以后做 X 就这样做的处方 —— `cs-trick`(产出 `doc_type: trick`)
- 全项目今后都得遵守的规定 —— `cs-decide`(产出 `doc_type: decision`)
- 调查了一个问题,留份证据 —— `cs-explore`(产出 `doc_type: explore`)
四者共用 `codestable/compound/` 目录,靠 frontmatter 的 `doc_type` 字段和文件名中间的类型段(`YYYY-MM-DD-{doc_type}-{slug}.md`)区分。每个子技能只认自己的 `doc_type`,不读写别家产物——**"A 和 B 有什么不同"这种判断由本节负责,子技能里不再重复**。
## 现状档案 vs 规划档案 vs 单次动作
三类文档各管一段时间尺度,不要混:
- **现状档案**(requirements / architecture)——描述"系统现在长什么样"。默认只在 feature-acceptance 时跟着代码一起更新;必要时由 requirements / architecture 技能主动刷新。**不写"接下来打算做什么"**
- **规划档案**(roadmap)——描述"接下来打算怎么走"。独立于现状档案,改动不牵连 requirements / architecture。所有条目 done / dropped 后 roadmap 进入 `completed` 状态,作为历史档案留存
- **单次动作**(feature / issue / refactor)——本次要做的一件具体事情的 spec。动作走完后,相关沉淀提炼进现状档案和沉淀类文档
用户说"我想要一个 X 系统"这种大需求,先走 roadmap 拆成若干子 feature,再一条一条走 feature 流程。直接起 feature 会变成巨型 design 塞不下、拆了又没有追踪抓手。
## feature 和 issue 的阶段不可跳
feature 走 brainstorm(可选) → design → implement → acceptance,issue 走 report → analyze → fix。每个阶段有退出条件,上一个没满足,下一个不开始。
AI 最常见的问题是一口气铺几百行代码才让人看——等发现问题已经很难中止。阶段间的人工 checkpoint 就是为了早一步中止。每个 checkpoint 具体检查什么,对应子技能里讲。
例外两种:issue 根因一眼确定时走快速通道,跳过 analyze 直接 fix;feature 范围小时走 `cs-feat-ff`,写完 spec 直接进实现。
## 进一步参考
- `codestable/reference/shared-conventions.md` — 目录结构、YAML frontmatter 口径、`{slug}-checklist.yaml` 生命周期、收尾 commit 约定、归档类共享规则
- `codestable/reference/tools.md``search-yaml.py` / `validate-yaml.py` 用法
- `codestable/reference/maintainer-notes.md` — 断点恢复、新增子工作流的登记
目录结构(requirements/、architecture/、roadmap/、features/、issues/、compound/、tools/、reference/)的权威定义在 `shared-conventions.md`。要改目录先改那里——方法是改 `cs-onboard/reference/shared-conventions.md` 这个模板,新项目 onboarding 时会带上新版本。
## 相关
- `AGENTS.md` — 全项目代码规范和已知坑
- `codestable/architecture/ARCHITECTURE.md` — 项目架构总入口

View File

@@ -0,0 +1,98 @@
# CodeStable 工具用法参考
本文件由 `cs-onboard` 复制到项目的 `codestable/reference/tools.md`,所有 CodeStable 子技能用项目相对路径 `codestable/reference/tools.md` 引用。
`codestable/tools/` 下共享脚本的完整用法参考。子技能里只写本技能特有的 1-2 行典型查询;完整语法和示例看这里。
---
## 1. search-yaml.py
通用 YAML frontmatter 搜索工具。从项目根目录运行无需安装额外依赖PyYAML 可选,有则用,无则内建 fallback parser
### 基本语法
```bash
python codestable/tools/search-yaml.py --dir {目录} [--filter key=value]... [--query "全文关键词"] [--sort-by FIELD [--order asc|desc]] [--full] [--json]
```
### filter 语法
- `key=value`:字段精确匹配(大小写不敏感)
- `key~=value`:字符串字段子串匹配;列表字段元素包含匹配
### 排序语法
- `--sort-by FIELD`:按 frontmatter 字段排序(典型字段:`last_reviewed``date``updated_at`
- `--order desc|asc``desc` 默认,新的在前;`asc` 老的在前(查"谁最久没更新"用这个)
- 字段缺失 / 值为空的文档一律排到最后,不干扰前排结论
### 常用命令
沉淀类文档统一在 `codestable/compound/`,用 `doc_type` 字段区分四个子技能的产物,内部还有各自的细分字段:
```bash
# 按 doc_type 筛选
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter status=active
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=explore --filter status=active
# doc_type + 子技能内部细分字段
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=pitfall
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter category=constraint
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter type=pattern
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=explore --filter type=question
# 按 tag列表元素包含匹配
python codestable/tools/search-yaml.py --dir codestable/compound --filter tags~=prisma
# 全文搜索
python codestable/tools/search-yaml.py --dir codestable/compound --query "shadow database"
# 按领域/框架/语言筛选
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter area=frontend
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter framework~=vue
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter language=typescript
# 搜索 feature 方案 doc
python codestable/tools/search-yaml.py --dir codestable/features --filter doc_type=feature-design --filter status=approved
# 输出控制
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active --full
python codestable/tools/search-yaml.py --dir codestable/compound --filter tags~=llm --json
# 按时间排序
python codestable/tools/search-yaml.py --dir codestable/compound --sort-by date --order desc # 最近归档的在前
python codestable/tools/search-yaml.py --dir codestable/library-docs --sort-by last_reviewed --order asc # 最久没 review 的在前(找陈旧文档)
python codestable/tools/search-yaml.py --dir codestable/guides --filter status=current --sort-by last_reviewed --order asc
```
### 典型使用场景
| 场景 | 命令建议 |
|---|---|
| feature-design 开始前查已有归档 | 搜 `codestable/compound` 目录,按 `--query "{关键词}"` 全文搜;要分类看就加 `--filter doc_type={learning\|trick\|decision\|explore}` |
| issue-analyze 根因分析前查历史 | 搜 `codestable/compound` `--filter doc_type=learning --filter track=pitfall`、再搜 `--filter doc_type=trick --filter type=library`,按相关组件/框架过滤 |
| 归档落盘后查重叠 | 搜 `codestable/compound --query "{关键词}" --json`,看有无语义重叠 |
| 新人了解项目规约 | `--dir codestable/compound --filter doc_type=decision --filter status=active` |
| 按技术栈浏览技巧 | `--dir codestable/compound --filter doc_type=trick --filter language={语言} --filter status=active` |
| 找最久没 review 的库文档 / 指南 | `--dir {目录} --filter status=current --sort-by last_reviewed --order asc` |
| 看最近沉淀了哪些经验 | `--dir codestable/compound --filter doc_type=learning --sort-by date --order desc` |
---
## 2. validate-yaml.py
YAML 语法校验工具。用于验证 frontmatter 语法和必填字段。
```bash
# 校验单个文件的 YAML 语法
python codestable/tools/validate-yaml.py --file {文件路径} --yaml-only
# 校验必填字段
python codestable/tools/validate-yaml.py --file {文件路径} --require doc_type --require status
# 批量校验目录下所有文件
python codestable/tools/validate-yaml.py --dir {目录} --require doc_type --require status
```

View File

View File

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
search-yaml.py — Generic YAML-frontmatter search tool for markdown document directories.
Works on any directory of .md files that use YAML frontmatter (--- ... ---).
Designed for AI agent use: fast, structured output, no required external dependencies.
Filter syntax (--filter flag, repeatable, AND logic):
key=value Exact match on a scalar field (case-insensitive)
key~=value Substring match on a string field, or element-in for list fields
Usage examples:
# Search codestable/compound (learning / trick / decision / explore docs share this dir)
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=pitfall
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=trick --filter tags~=prisma
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=decision --filter status=active --full
# Full-text search in body + frontmatter values
python codestable/tools/search-yaml.py --dir codestable/compound --query "shadow database"
# JSON output for AI agent consumption
python codestable/tools/search-yaml.py --dir codestable/compound --filter doc_type=learning --filter track=knowledge --json
# Sort by a frontmatter date field (works on any ISO-8601 date string, YAML date, or sortable value)
python codestable/tools/search-yaml.py --dir codestable/library-docs --sort-by last_reviewed --order asc # oldest first (stalest)
python codestable/tools/search-yaml.py --dir codestable/compound --sort-by date --order desc # newest first
# Works on any yaml-frontmatter markdown directory
python codestable/tools/search-yaml.py --dir docs/decisions --filter status=accepted
python codestable/tools/search-yaml.py --dir content/posts --filter tags~=python --query "asyncio"
"""
import argparse
import json
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Frontmatter parsing (PyYAML used when available, builtin fallback otherwise)
# ---------------------------------------------------------------------------
def _parse_yaml_scalar(val: str):
val = val.strip()
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
return [item.strip().strip("'\"") for item in inner.split(",") if item.strip()]
lower = val.lower()
if lower in ("true", "yes"):
return True
if lower in ("false", "no"):
return False
if lower in ("null", "~", ""):
return None
return val
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""
Split a markdown document into (frontmatter_dict, body_text).
Returns ({}, full_text) when no frontmatter is present.
"""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
fm_text = text[3:end].strip()
body = text[end + 4:].strip()
try:
import yaml # type: ignore
meta = yaml.safe_load(fm_text)
return (meta or {}), body
except Exception:
pass
# Minimal fallback: handles scalar values and inline lists
meta: dict = {}
for line in fm_text.splitlines():
if not line.strip() or line.startswith("#") or ":" not in line:
continue
key, _, raw = line.partition(":")
meta[key.strip()] = _parse_yaml_scalar(raw)
return meta, body
# ---------------------------------------------------------------------------
# Document loading
# ---------------------------------------------------------------------------
def load_documents(directory: Path) -> list[dict]:
docs = []
for md_file in sorted(directory.rglob("*.md")):
try:
text = md_file.read_text(encoding="utf-8")
except OSError as exc:
print(f"[warn] Cannot read {md_file.name}: {exc}", file=sys.stderr)
continue
meta, body = parse_frontmatter(text)
docs.append({
"file": str(md_file.relative_to(directory)),
"path": str(md_file),
"meta": meta,
"body": body,
})
return docs
# ---------------------------------------------------------------------------
# Filter parsing and evaluation
# ---------------------------------------------------------------------------
class Filter:
"""Parsed representation of a single --filter expression."""
def __init__(self, raw: str):
if "~=" in raw:
key, _, value = raw.partition("~=")
self.key = key.strip()
self.value = value.strip()
self.operator = "contains"
elif "=" in raw:
key, _, value = raw.partition("=")
self.key = key.strip()
self.value = value.strip()
self.operator = "exact"
else:
raise argparse.ArgumentTypeError(
f"Invalid filter expression {raw!r}. "
"Use 'key=value' for exact match or 'key~=value' for substring/list-contains match."
)
def matches(self, meta: dict) -> bool:
field_val = meta.get(self.key)
if field_val is None:
return False
if self.operator == "exact":
return str(field_val).lower() == self.value.lower()
# contains: substring for strings, element-in for lists
if isinstance(field_val, list):
return any(self.value.lower() == str(item).lower() for item in field_val)
return self.value.lower() in str(field_val).lower()
def __repr__(self):
op = "~=" if self.operator == "contains" else "="
return f"Filter({self.key}{op}{self.value})"
def parse_filter(raw: str) -> Filter:
"""argparse type converter for --filter."""
return Filter(raw)
_MISSING = object()
def _sort_key(doc: dict, field: str):
"""
Sort key for --sort-by. Docs missing the field sort to the end regardless
of --order. Dates (datetime.date / datetime.datetime) and strings are both
normalized to their string form — ISO 8601 date strings sort the same
lexicographically as YAML-parsed date objects' isoformat().
"""
val = doc["meta"].get(field, _MISSING)
if val is _MISSING or val is None:
return (1, "")
try:
return (0, val.isoformat()) # datetime.date / datetime.datetime
except AttributeError:
return (0, str(val))
def doc_matches(doc: dict, filters: list[Filter], query: str | None) -> bool:
meta = doc["meta"]
for f in filters:
if not f.matches(meta):
return False
if query:
needle = query.lower()
haystack = doc["body"].lower() + " " + " ".join(str(v) for v in meta.values()).lower()
if needle not in haystack:
return False
return True
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def _meta_summary(meta: dict) -> str:
"""One-line summary of frontmatter fields, skipping slug/date for brevity."""
skip = {"slug"}
parts = []
for k, v in meta.items():
if k in skip:
continue
if isinstance(v, list):
parts.append(f"{k}=[{', '.join(str(i) for i in v)}]")
else:
parts.append(f"{k}={v}")
return " ".join(parts)
def format_summary(doc: dict) -> str:
return f"### {doc['file']}\n{_meta_summary(doc['meta'])}"
def format_full(doc: dict) -> str:
return format_summary(doc) + "\n\n" + doc["body"]
def print_text(results: list[dict], full: bool) -> None:
print(f"Found {len(results)} document(s).\n")
sep = "\n" + "" * 60 + "\n"
chunks = [format_full(d) if full else format_summary(d) for d in results]
print(sep.join(chunks))
def print_json(results: list[dict], full: bool) -> None:
output = []
for doc in results:
body = doc["body"]
if not full and len(body) > 400:
body = body[:400] + ""
output.append({"file": doc["file"], "meta": doc["meta"], "body": body})
print(json.dumps(output, ensure_ascii=False, indent=2))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Generic YAML-frontmatter search across a directory of markdown files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("--dir", metavar="DIR", required=True,
help="Directory of .md files to search.")
parser.add_argument("--filter", "-f", metavar="EXPR", dest="filters",
type=parse_filter, action="append", default=[],
help="Frontmatter filter expression. Repeatable (AND logic). "
"key=value for exact match; key~=value for substring (strings) or element-in (lists).")
parser.add_argument("--query", "-q", metavar="TEXT",
help="Full-text search in document body and frontmatter values.")
parser.add_argument("--full", action="store_true",
help="Print full document body instead of just the frontmatter summary.")
parser.add_argument("--json", dest="as_json", action="store_true",
help="Output results as a JSON array.")
parser.add_argument("--sort-by", metavar="FIELD", dest="sort_by",
help="Sort results by a frontmatter field (e.g. last_reviewed, date, updated_at). "
"ISO-8601 date strings and YAML-parsed dates both sort correctly. "
"Docs missing the field are pushed to the end.")
parser.add_argument("--order", choices=("asc", "desc"), default="desc",
help="Sort order when --sort-by is set. Default: desc (newest first).")
return parser
def _resolve_directory(dir_arg: str) -> Path:
directory = Path(dir_arg)
if not directory.exists():
print(f"[error] Directory not found: {directory}", file=sys.stderr)
sys.exit(1)
if not directory.is_dir():
print(f"[error] Not a directory: {directory}", file=sys.stderr)
sys.exit(1)
return directory
def _sort_results(results: list[dict], sort_by: str, order: str) -> list[dict]:
def has_field(d: dict) -> bool:
return sort_by in d["meta"] and d["meta"][sort_by] is not None
present = [d for d in results if has_field(d)]
missing = [d for d in results if not has_field(d)]
present.sort(key=lambda d: _sort_key(d, sort_by), reverse=(order == "desc"))
return present + missing
def main() -> None:
args = _build_parser().parse_args()
directory = _resolve_directory(args.dir)
docs = load_documents(directory)
if not docs:
print(f"No .md files found in {directory}")
return
results = [d for d in docs if doc_matches(d, args.filters, args.query)]
if not results:
print("No matching documents found.")
return
if args.sort_by:
results = _sort_results(results, args.sort_by, args.order)
if args.as_json:
print_json(results, full=args.full)
else:
print_text(results, full=args.full)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""
validate-yaml.py — Validate YAML frontmatter syntax in markdown files.
Scans markdown files for YAML frontmatter (--- ... ---) and checks:
1. Frontmatter block is properly delimited (opening and closing ---)
2. YAML syntax is valid (parseable without errors)
3. (Optional) Required fields are present (--require flag)
Designed for AI agent use: structured output, exit code reflects pass/fail,
no required external dependencies (falls back to builtin parser if PyYAML unavailable).
Usage examples:
# Validate all .md files under codestable/features
python codestable/tools/validate-yaml.py --dir codestable/features
# Validate a single file
python codestable/tools/validate-yaml.py --file codestable/features/2026-04-11-auth/auth-design.md
# Check that required fields exist in frontmatter
python codestable/tools/validate-yaml.py --dir codestable/features --require doc_type --require status
# JSON output for programmatic consumption
python codestable/tools/validate-yaml.py --dir docs/api --json
# Validate the libdoc manifest
python codestable/tools/validate-yaml.py --file docs/api/manifest.yaml --yaml-only
"""
import argparse
import json
import sys
from pathlib import Path
# Force UTF-8 stdout/stderr on Windows where default codepage (e.g. GBK / cp936)
# can't encode the ✓ / ✗ icons used in text output. Safe no-op on POSIX.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
# ---------------------------------------------------------------------------
# YAML parsing
# ---------------------------------------------------------------------------
_HAS_PYYAML = False
try:
import yaml # type: ignore
_HAS_PYYAML = True
except ImportError:
pass
def _builtin_parse_yaml(text: str) -> dict:
"""Minimal YAML parser for flat key-value frontmatter (no nested structures)."""
result: dict = {}
for line in text.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, _, raw = stripped.partition(":")
val = raw.strip()
# Inline list
if val.startswith("[") and val.endswith("]"):
inner = val[1:-1]
result[key.strip()] = [
item.strip().strip("'\"") for item in inner.split(",") if item.strip()
]
else:
result[key.strip()] = val.strip("'\"") if val else ""
return result
def parse_yaml_text(text: str) -> tuple[dict | None, str | None]:
"""
Parse a YAML string. Returns (parsed_dict, None) on success,
or (None, error_message) on failure.
"""
if _HAS_PYYAML:
try:
result = yaml.safe_load(text)
if result is None:
return {}, None
if not isinstance(result, dict):
return None, f"Expected a mapping, got {type(result).__name__}"
return result, None
except yaml.YAMLError as exc:
return None, str(exc)
else:
# Builtin fallback — can only detect gross syntax issues
try:
result = _builtin_parse_yaml(text)
return result, None
except Exception as exc:
return None, str(exc)
# ---------------------------------------------------------------------------
# Frontmatter extraction
# ---------------------------------------------------------------------------
def extract_frontmatter(text: str) -> tuple[str | None, str | None]:
"""
Extract YAML frontmatter from a markdown file.
Returns (frontmatter_text, None) on success,
or (None, error_message) if frontmatter is missing or malformed.
"""
if not text.startswith("---"):
return None, "No opening '---' delimiter found"
end = text.find("\n---", 3)
if end == -1:
return None, "No closing '---' delimiter found (frontmatter block not terminated)"
fm_text = text[3:end].strip()
if not fm_text:
return None, "Frontmatter block is empty"
return fm_text, None
# ---------------------------------------------------------------------------
# Validation logic
# ---------------------------------------------------------------------------
class ValidationResult:
def __init__(self, file_path: str):
self.file = file_path
self.errors: list[str] = []
self.warnings: list[str] = []
self.fields: list[str] = [] # fields found in frontmatter
@property
def ok(self) -> bool:
return len(self.errors) == 0
def to_dict(self) -> dict:
d: dict = {"file": self.file, "status": "pass" if self.ok else "fail"}
if self.errors:
d["errors"] = self.errors
if self.warnings:
d["warnings"] = self.warnings
if self.fields:
d["fields"] = self.fields
return d
def _check_required(parsed: dict | None, required_fields: list[str] | None, result: ValidationResult) -> None:
if not required_fields:
return
for field in required_fields:
if field not in (parsed or {}):
result.errors.append(f"Missing required field: '{field}'")
def _warn_if_builtin(result: ValidationResult) -> None:
if not _HAS_PYYAML:
result.warnings.append(
"PyYAML not installed — using builtin fallback parser "
"(may miss some syntax errors). Install with: pip install pyyaml"
)
def _validate_file(
file_path: Path,
required_fields: list[str] | None,
base_dir: Path | None,
mode: str, # "markdown" | "yaml"
) -> ValidationResult:
display_path = str(file_path.relative_to(base_dir)) if base_dir else str(file_path)
result = ValidationResult(display_path)
try:
text = file_path.read_text(encoding="utf-8")
except OSError as exc:
result.errors.append(f"Cannot read file: {exc}")
return result
if mode == "markdown":
yaml_text, extract_err = extract_frontmatter(text)
if extract_err:
result.errors.append(extract_err)
return result
else:
yaml_text = text
parsed, parse_err = parse_yaml_text(yaml_text)
if parse_err:
result.errors.append(f"YAML syntax error: {parse_err}")
return result
result.fields = list(parsed.keys()) if parsed else []
_check_required(parsed, required_fields, result)
_warn_if_builtin(result)
return result
def validate_markdown_file(file_path, required_fields=None, base_dir=None):
"""Validate YAML frontmatter in a single markdown file."""
return _validate_file(file_path, required_fields, base_dir, "markdown")
def validate_yaml_file(file_path, required_fields=None, base_dir=None):
"""Validate a pure YAML file (not markdown with frontmatter)."""
return _validate_file(file_path, required_fields, base_dir, "yaml")
# ---------------------------------------------------------------------------
# Output
# ---------------------------------------------------------------------------
def print_text_results(results: list[ValidationResult]) -> None:
passed = sum(1 for r in results if r.ok)
failed = len(results) - passed
print(f"Validated {len(results)} file(s): {passed} passed, {failed} failed.\n")
for r in results:
icon = "" if r.ok else ""
print(f" {icon} {r.file}")
for err in r.errors:
print(f" ERROR: {err}")
for warn in r.warnings:
print(f" WARN: {warn}")
if failed > 0:
print(f"\n{failed} file(s) have YAML errors.")
else:
print("\nAll files valid.")
def print_json_results(results: list[ValidationResult]) -> None:
output = {
"total": len(results),
"passed": sum(1 for r in results if r.ok),
"failed": sum(1 for r in results if not r.ok),
"results": [r.to_dict() for r in results],
}
print(json.dumps(output, indent=2, ensure_ascii=False))
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Validate YAML frontmatter in markdown files or pure YAML files.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument("--dir", type=str, help="Directory to scan recursively for .md files")
source.add_argument("--file", type=str, help="Single file to validate")
parser.add_argument("--require", action="append", default=[], metavar="FIELD",
help="Require this field in frontmatter (repeatable)")
parser.add_argument("--json", action="store_true", dest="json_output",
help="Output results as JSON")
parser.add_argument("--yaml-only", action="store_true",
help="Treat input as pure YAML (not markdown with frontmatter). "
"Use for .yaml/.yml files like manifest.yaml.")
return parser
def _validate_single(path_str: str, require: list[str], yaml_only: bool) -> list[ValidationResult]:
fp = Path(path_str)
if not fp.exists():
print(f"Error: File not found: {fp}", file=sys.stderr)
sys.exit(2)
if yaml_only or fp.suffix in (".yaml", ".yml"):
return [validate_yaml_file(fp, require)]
return [validate_markdown_file(fp, require)]
def _validate_directory(dir_str: str, require: list[str]) -> list[ValidationResult]:
dp = Path(dir_str)
if not dp.is_dir():
print(f"Error: Directory not found: {dp}", file=sys.stderr)
sys.exit(2)
md_files = sorted(dp.rglob("*.md"))
yaml_files = sorted(dp.rglob("*.yaml")) + sorted(dp.rglob("*.yml"))
if not md_files and not yaml_files:
print(f"No .md or .yaml files found under {dp}", file=sys.stderr)
sys.exit(2)
results = [validate_markdown_file(md, require, dp) for md in md_files]
results += [validate_yaml_file(yf, require, dp) for yf in yaml_files]
return results
def main() -> None:
args = _build_parser().parse_args()
if args.file:
results = _validate_single(args.file, args.require, args.yaml_only)
else:
results = _validate_directory(args.dir, args.require)
if args.json_output:
print_json_results(results)
else:
print_text_results(results)
sys.exit(0 if all(r.ok for r in results) else 1)
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -137,9 +137,6 @@ const renderInlineLinks = (text: string): JSX.Element[] => {
const App: ParentComponent = (props) => {
const location = useLocation();
const [version] = createResource(versionApi);
const [copyState, setCopyState] = createSignal<"idle" | "done" | "error">(
"idle",
);
const [updateDialogOpen, setUpdateDialogOpen] = createSignal(false);
const [updateCheckState, setUpdateCheckState] =
createSignal<UpdateCheckState>("idle");
@@ -197,31 +194,6 @@ const App: ParentComponent = (props) => {
return error instanceof Error ? error.message : "版本信息获取失败";
});
const versionPayloadText = createMemo(() =>
[
`Version: ${versionText()}`,
`Mode: ${modeText()}`,
`Commit: ${commitText()}`,
`Build: ${buildText()}`,
`Author: ${authorText()}`,
`Email: ${emailText()}`,
].join("\n"),
);
const updateSummaryText = createMemo(() => {
if (updateCheckState() === "checking") {
return "更新检查中...";
}
if (updateCheckState() === "available") {
return `发现新版本:${latestRelease()?.tag_name ?? "-"}`;
}
if (updateCheckState() === "latest") {
return "已是最新版本";
}
if (updateCheckState() === "error") {
return updateCheckError() || "更新检查失败";
}
return "未检查更新";
});
const releaseNotesBlocks = createMemo(() =>
parseMarkdownBlocks(latestRelease()?.body ?? ""),
);
@@ -231,6 +203,14 @@ const App: ParentComponent = (props) => {
const releaseLink = createMemo(
() => latestRelease()?.html_url || RELEASES_PAGE_URL,
);
const safeValue = (value: string) => (value === "unknown" ? "-" : value);
const hasUpdateBadge = createMemo(() => updateCheckState() === "available");
const updateDialogTitle = createMemo(() => {
if (updateCheckState() === "available") {
return `发现更新 ${latestRelease()?.tag_name ?? ""}`;
}
return "更新信息";
});
onMount(() => {
const unsubscribe = settingsStore.subscribe((state) => {
@@ -242,16 +222,6 @@ const App: ParentComponent = (props) => {
});
});
const handleCopyVersion = async () => {
try {
await navigator.clipboard.writeText(versionPayloadText());
setCopyState("done");
} catch {
setCopyState("error");
}
window.setTimeout(() => setCopyState("idle"), 1800);
};
const performUpdateCheck = async (manual = false) => {
if (updateCheckState() === "checking") {
return;
@@ -276,17 +246,17 @@ const App: ParentComponent = (props) => {
setRuntimeTarget(target);
const hasNewVersion = isRemoteVersionNewer(versionText(), release.tag_name);
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
if (!hasNewVersion) {
setUpdateCheckState("latest");
if (manual) {
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
setUpdateDialogOpen(true);
}
return;
}
setLatestRelease(release);
setMatchedAsset(resolveAssetForRuntime(release.assets, target));
setUpdateCheckState("available");
setUpdateDialogOpen(true);
} catch (error) {
@@ -400,7 +370,7 @@ const App: ParentComponent = (props) => {
class={
active
? "min-w-fit whitespace-nowrap rounded-2xl border border-cyan-200 bg-[linear-gradient(135deg,_rgba(34,211,238,0.18),_rgba(34,197,94,0.18))] px-4 py-3 text-sm font-medium text-zinc-900 shadow-sm"
: "min-w-fit whitespace-nowrap rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
: "min-w-fit whitespace-nowrap rounded-2xl border border-transparent px-4 py-3 text-sm font-medium text-zinc-600 transition active:scale-[0.98] hover:border-zinc-200 hover:bg-zinc-50/80 hover:text-zinc-900"
}
>
<div class="flex items-center justify-between">
@@ -425,48 +395,48 @@ const App: ParentComponent = (props) => {
<p class="mt-1 text-sm text-zinc-500">
{asideList().find((item) => isActive(item.url))?.label ?? "账号"}
</p>
<p class="mt-1 text-xs text-zinc-500">
: {modeText()}
</p>
<p class="mt-1 text-xs text-zinc-500">
: {isDebugMode() ? "已开启" : "已关闭"}
</p>
<p class="mt-3 text-xs font-medium tracking-[0.18em] text-cyan-700/75 uppercase">
Runtime
</p>
<div class="mt-2 grid gap-1 text-xs text-zinc-500 xl:block">
<p>Version: {versionText()}</p>
<p>Commit: {commitText()}</p>
<p>Build: {buildText()}</p>
<p>Author: {authorText()}</p>
<p>Email: {emailText()}</p>
</div>
<p
class={`mt-2 text-xs ${updateCheckState() === "error" ? "text-rose-500" : "text-zinc-500"}`}
>
: {updateSummaryText()}
</p>
<div class="mt-3 flex flex-wrap items-center gap-2">
<div class="mt-3 border-t border-zinc-200/80 pt-3">
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100"
onClick={() => void handleCopyVersion()}
>
{copyState() === "done"
? "已复制"
: copyState() === "error"
? "复制失败"
: "复制版本信息"}
</button>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-3 py-1.5 text-xs text-cyan-700 transition hover:bg-cyan-100 disabled:cursor-not-allowed disabled:opacity-60"
class="flex w-full min-w-0 items-center gap-2 rounded-lg border border-zinc-200 bg-white/80 px-2.5 py-1.5 text-left text-xs text-zinc-500 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={updateCheckState() === "checking"}
onClick={() => void performUpdateCheck(true)}
title="点击查看更新内容"
>
{updateCheckState() === "checking" ? "检查中..." : "检查更新"}
<span class="text-zinc-400"></span>
<span class="shrink-0 rounded-full border border-zinc-200 bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{safeValue(versionText())}
</span>
{hasUpdateBadge() ? (
<span
class="h-2 w-2 shrink-0 rounded-full bg-rose-500"
title={`新版本:${latestRelease()?.tag_name ?? "-"}`}
/>
) : null}
<span class="ml-auto text-[11px] text-zinc-400">
{updateCheckState() === "checking" ? "检查中..." : "查看更新"}
</span>
</button>
<details class="mt-2 rounded-lg border border-zinc-200/80 bg-white/70 px-2.5 py-2">
<summary class="cursor-pointer select-none text-[11px] text-zinc-500">
</summary>
<div class="mt-2 space-y-1 text-xs">
<p class={isDebugMode() ? "text-amber-600" : "text-zinc-600"}>
Mode: {safeValue(modeText())}
</p>
<p class="truncate text-zinc-600">Commit: {safeValue(commitText())}</p>
<p class="truncate text-zinc-600">Build: {safeValue(buildText())}</p>
<p class="truncate text-zinc-600">Author: {safeValue(authorText())}</p>
<p class="truncate text-zinc-600">Email: {safeValue(emailText())}</p>
</div>
</details>
</div>
{updateCheckState() === "error" ? (
<p class="mt-2 text-xs text-rose-500">
{updateCheckError() || "更新检查失败"}
</p>
) : null}
{versionErrorText() ? (
<p class="mt-2 text-xs text-rose-500">{versionErrorText()}</p>
) : null}
@@ -488,14 +458,14 @@ const App: ParentComponent = (props) => {
}
setUpdateDialogOpen(false);
}}
title={`发现更新 ${latestRelease()?.tag_name ?? ""}`}
title={updateDialogTitle()}
widthClass="max-w-3xl"
closeOnOverlay={downloadState() !== "downloading"}
footer={
<>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition active:bg-zinc-200 hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"}
onClick={() => setUpdateDialogOpen(false)}
>
@@ -503,14 +473,14 @@ const App: ParentComponent = (props) => {
</button>
<button
type="button"
class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition hover:bg-cyan-100"
class="rounded-xl border border-cyan-200 bg-cyan-50 px-3 py-2 text-sm text-cyan-700 transition active:bg-cyan-200 hover:bg-cyan-100"
onClick={openReleasePage}
>
Release
</button>
<button
type="button"
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm text-white transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-xl bg-[linear-gradient(135deg,#06b6d4,#14b8a6)] px-3 py-2 text-sm text-white transition active:scale-[0.97] hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-60"
disabled={downloadState() === "downloading"}
onClick={() => void handleDownloadUpdate()}
>

View File

@@ -89,7 +89,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<div class="flex items-start justify-between gap-3">
<button
type="button"
class="min-w-0 flex-1 text-left"
class="min-w-0 flex-1 text-left transition active:scale-[0.98]"
onClick={() => props.onSelectAccount(account.id)}
>
<div class="flex items-start justify-between gap-3">
@@ -119,7 +119,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</button>
<button
type="button"
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600"
class="shrink-0 text-sm text-zinc-400 transition hover:text-zinc-600 active:text-zinc-800"
onClick={() => props.onToggleExpand(account.id)}
>
{expanded() ? "收起" : "展开"}
@@ -139,7 +139,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
<div class="mt-1.5 flex flex-wrap gap-1.5 sm:col-span-2">
<button
type="button"
class="rounded-xl border border-emerald-200 bg-white px-2.5 py-1 text-xs text-emerald-700 transition hover:bg-emerald-50 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-xl border border-emerald-200 bg-white px-2.5 py-1 text-xs text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing()}
onClick={(event) => {
event.stopPropagation();
@@ -150,14 +150,14 @@ const AccountSidebar = (props: AccountSidebarProps) => {
</button>
<button
type="button"
class="rounded-xl border border-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50"
class="rounded-xl border border-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50 active:bg-cyan-100"
onClick={() => props.onToggleExpand(account.id)}
>
</button>
<button
type="button"
class="rounded-xl border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-500 transition hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-xl border border-rose-200 bg-white px-2.5 py-1 text-xs text-rose-500 transition hover:bg-rose-50 active:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.loggingOutId === account.id}
onClick={(event) => {
event.stopPropagation();

View File

@@ -43,14 +43,14 @@ const AddAccountDialog = (props: AddAccountDialogProps) => {
<>
<button
type="button"
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100"
class="rounded-xl border border-zinc-200 px-4 py-2 text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClose}
>
</button>
<button
type="button"
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 disabled:cursor-not-allowed disabled:bg-cyan-300"
class="rounded-xl bg-cyan-500 px-4 py-2 text-white transition hover:bg-cyan-600 active:bg-cyan-700 disabled:cursor-not-allowed disabled:bg-cyan-300"
disabled={props.isSubmitting}
onClick={props.onSubmit}
>

View File

@@ -6,11 +6,13 @@ import {
createSignal,
type JSX,
} from "solid-js";
import type { CourseKind, RecordType } from "~/service/wk";
import type { CourseKind, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { AccountItem } from "~/store/account";
import type { CourseType } from "~/types/Course";
import type { RecordItem } from "~/service/wk";
const stripHtml = (value: string) => value.replace(/<[^>]+>/g, "").trim();
type RecordTypeOption = {
label: string;
value: RecordType;
@@ -33,6 +35,8 @@ interface CourseWorkspaceProps {
recordTypeOptions: RecordTypeOption[];
courseRecordTypeOptions: CourseRecordTypeOption[];
records: RecordItem[];
workList: WorkListItem[];
examList: ExamListItem[];
studyLogs: string[];
recordsLoading: boolean;
recordError: string;
@@ -179,7 +183,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</select>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingCourseRecords}
onClick={props.onRefreshCourseRecords}
>
@@ -232,8 +236,8 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div
class={
compact()
? "grid min-h-0 gap-1.5 grid-rows-[minmax(0,1fr)_176px] xl:grid-rows-[minmax(0,1fr)_188px]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_188px] xl:grid-rows-[minmax(0,1fr)_208px]"
? "grid min-h-0 gap-1.5 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
: "grid min-h-0 gap-2 grid-rows-[minmax(0,1fr)_minmax(140px,30vh)]"
}
>
<div class="flex min-h-0 flex-col overflow-hidden rounded-[18px] border border-zinc-200 bg-[linear-gradient(180deg,rgba(248,250,252,0.9),rgba(255,255,255,0.95))]">
@@ -259,7 +263,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div class="flex flex-wrap items-center gap-1.5">
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-lg border border-zinc-200 bg-white px-2 py-1 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={
!props.selectedCourse || props.isRefreshingRecords
}
@@ -279,7 +283,13 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
>
<For each={props.recordTypeOptions}>
{(item) => (
<option value={item.value}>{item.label}</option>
<option
value={item.value}
disabled={item.value === "/discuss"}
class={item.value === "/discuss" ? "text-zinc-400" : ""}
>
{item.label}
</option>
)}
</For>
</select>
@@ -287,7 +297,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<Show when={props.recordType === ""}>
<button
type="button"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100"
class="rounded-lg border border-cyan-200 bg-cyan-50 px-2 py-1 text-xs text-cyan-700 transition hover:bg-cyan-100 active:bg-cyan-200"
onClick={
props.isRunningStudy
? props.onStopStudy
@@ -299,25 +309,26 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</Show>
</div>
</div>
<Show when={props.recordType === ""}>
<div class="flex flex-wrap items-center justify-between gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5">
<div class="flex flex-wrap items-center gap-1.5 text-xs">
<button
type="button"
class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100"}
class={recordFilter() === "all" ? "rounded-full border border-zinc-200 bg-zinc-100 px-2.5 py-0.5 font-medium text-zinc-700 active:bg-zinc-200" : "rounded-full border border-zinc-200 bg-white px-2.5 py-0.5 text-zinc-600 transition hover:bg-zinc-100 active:bg-zinc-200"}
onClick={() => setRecordFilter("all")}
>
{recordStats().total}
</button>
<button
type="button"
class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50"}
class={recordFilter() === "unlearned" ? "rounded-full border border-amber-200 bg-amber-100 px-2.5 py-0.5 font-medium text-amber-700 active:bg-amber-200" : "rounded-full border border-amber-200 bg-white px-2.5 py-0.5 text-amber-700 transition hover:bg-amber-50 active:bg-amber-100"}
onClick={() => setRecordFilter("unlearned")}
>
{recordStats().unlearned}
</button>
<button
type="button"
class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50"}
class={recordFilter() === "learned" ? "rounded-full border border-emerald-200 bg-emerald-100 px-2.5 py-0.5 font-medium text-emerald-700 active:bg-emerald-200" : "rounded-full border border-emerald-200 bg-white px-2.5 py-0.5 text-emerald-700 transition hover:bg-emerald-50 active:bg-emerald-100"}
onClick={() => setRecordFilter("learned")}
>
{recordStats().learned}
@@ -327,6 +338,17 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
{recordFilter() === "all" ? "全部" : recordFilter() === "unlearned" ? "未学" : "已学"}
</p>
</div>
</Show>
<Show when={props.recordType === "/work" && props.workList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.workList.length}
</div>
</Show>
<Show when={props.recordType === "/exam" && props.examList.length > 0}>
<div class="flex items-center gap-1.5 border-b border-zinc-200/70 px-2.5 py-1.5 text-xs text-zinc-500">
{props.examList.length}
</div>
</Show>
<div
class={
@@ -360,6 +382,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "" &&
filteredRecords().length === 0
}
>
@@ -370,59 +393,195 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</EmptyState>
</Show>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={filteredRecords()}>
{(record) => {
const stateText =
props.renderRecordState(record.state) || "未知状态";
const learned =
stateText.includes("已学") || record.progress === "1.00";
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/work" &&
props.workList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
return (
<div
class={
learned
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
<Show
when={
!props.recordsLoading &&
!props.recordError &&
props.selectedCourse &&
props.recordType === "/exam" &&
props.examList.length === 0
}
>
<EmptyState></EmptyState>
</Show>
<Show when={props.recordType === ""}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={filteredRecords()}>
{(record) => {
const stateText =
props.renderRecordState(record.state) || "未知状态";
const learned =
stateText.includes("已学") || record.progress === "1.00";
return (
<div
class={
learned
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{record.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{record.id} | {record.chapterId}
</p>
</div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<span
class={
learned
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{record.videoDuration}</p>
<p>{record.duration}</p>
<p>{record.progress}</p>
<p>{record.beginTime || "-"}</p>
<p>{record.finalTime || "-"}</p>
<p>{record.viewCount}</p>
</div>
</div>
</div>
);
}}
</For>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/work"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.workList}>
{(work) => {
const stateRaw = stripHtml(work.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{work.title || work.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{work.id} | {work.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{work.typeName || work.type || "-"}</p>
<p>{work.score || "-"}</p>
<p>{stripHtml(work.finalScore) || "-"}</p>
<p>{work.topicNumber || "-"}</p>
<p>{work.addTime || "-"}</p>
<p>{work.finishTime !== "-" ? work.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
<Show when={props.recordType === "/exam"}>
<div class={compact() ? "flex flex-col gap-1.5" : "flex flex-col gap-2"}>
<For each={props.examList}>
{(exam) => {
const stateRaw = stripHtml(exam.state);
const stateText = stateRaw || "未做";
const done = stateRaw.includes("已阅") || stateRaw.includes("已提交") || stateRaw.includes("已完成");
return (
<div
class={
done
? compact()
? "rounded-[16px] border border-emerald-200 bg-emerald-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-emerald-200 bg-emerald-50/35 px-2.5 py-2.5 shadow-sm"
: compact()
? "rounded-[16px] border border-amber-200 bg-amber-50/35 px-2 py-1.5 shadow-sm"
: "rounded-[18px] border border-amber-200 bg-amber-50/35 px-2.5 py-2.5 shadow-sm"
}
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-zinc-900">
{exam.title || exam.name}
</p>
<p class="mt-1 text-xs text-zinc-500">
ID{exam.id} | {exam.chapterId}
</p>
</div>
<span
class={
done
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-medium text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-medium text-amber-700"
}
>
{stateText}
</span>
</div>
<div class="mt-2 grid gap-1 text-xs text-zinc-600 md:grid-cols-2 xl:grid-cols-3">
<p>{exam.limitedTime ? `${exam.limitedTime}分钟` : "-"}</p>
<p>{exam.score || "-"}</p>
<p>{stripHtml(exam.finalScore) || "-"}</p>
<p>{exam.topicNumber || "-"}</p>
<p>{exam.addTime || "-"}</p>
<p>{exam.finishTime !== "-" ? exam.finishTime : "-"}</p>
</div>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
@@ -440,7 +599,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={props.isRefreshingLogs}
onClick={props.onRefreshLogs}
>
@@ -448,7 +607,7 @@ const CourseWorkspace = (props: CourseWorkspaceProps) => {
</button>
<button
type="button"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100"
class="rounded-lg border border-zinc-200 px-2 py-0.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={props.onClearLogs}
>

View File

@@ -1,39 +0,0 @@
import type { CourseType } from "~/types/Course";
import type { Accessor } from "solid-js";
const courseLabel: [string, keyof CourseType][] = [
["课程", "name"],
["课程号", "id"],
["老师", "teacher"],
["进度", "progress"],
["开始时间", "start_time"],
["结束时间", "stop_time"],
["学分", "credit"],
["类型", "type"],
];
interface CourseListProps {
courseList: Accessor<CourseType[]>;
}
const CourseList = (props: CourseListProps) => {
return (
<div class="m4-2 flex min-h-0 flex-1 flex-col gap-y-4 overflow-y-auto p-5">
{props.courseList().map((course) => {
return (
<div class="grid grid-cols-3 rounded-xl border bg-zinc-200 p-4 py-2 shadow-sm">
{courseLabel.map(([labelText, field]) => {
return (
<p class="text-base leading-7">
{labelText}: <span>{course[field]}</span>
</p>
);
})}
</div>
);
})}
</div>
);
};
export default CourseList;

View File

@@ -60,7 +60,7 @@ const Dialog: ParentComponent<DialogProps> = (props) => {
</h2>
<button
type="button"
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900"
class="rounded-lg px-3 py-1 text-zinc-500 transition hover:bg-zinc-100 hover:text-zinc-900 active:bg-zinc-200"
onClick={props.onClose}
>

View File

@@ -20,8 +20,11 @@ import {
type CourseKind,
type RecordItem,
type RecordType,
type WorkListItem,
type ExamListItem,
} from "~/service/wk";
import { setUnauthorizedHandler } from "~/service/http";
import { startSilentAudio, stopSilentAudio } from "~/service/silentAudio";
import { accountStore, type AccountItem } from "~/store/account";
import { getMergedHosts, settingsStore } from "~/store/settings";
import type { CourseType } from "~/types/Course";
@@ -196,7 +199,10 @@ const Account = () => {
const courseKind = createMemo(() => storeState().courseKind);
const recordType = createMemo(() => storeState().recordType);
const records = createMemo(() => storeState().records);
const workList = createMemo(() => storeState().workList);
const examList = createMemo(() => storeState().examList);
const recordCacheMap = createMemo(() => storeState().recordCacheMap);
const workExamCacheMap = createMemo(() => storeState().workExamCacheMap);
const studyLogsMap = createMemo(() => storeState().studyLogsMap);
const runningStudyMap = createMemo(() => storeState().runningStudyMap);
const mergedHostOptions = createMemo(() =>
@@ -567,33 +573,83 @@ const Account = () => {
setRecordError("");
try {
const res = await getAccountClient(accountId).recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const cacheKey = createRecordCacheKey(accountId, courseId, nextRecordType);
const client = getAccountClient(accountId);
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
if (nextRecordType === "/work") {
const res = await client.workListApi({
course_id: String(courseId),
page: 0,
record_type: "/work",
});
if (requestToken !== recordRequestToken) return;
accountStore.getState().setRecords(list);
accountStore
.getState()
.setRecordCache(
createRecordCacheKey(accountId, courseId, nextRecordType),
list,
);
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as WorkListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setWorkList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setExamList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else if (nextRecordType === "/exam") {
const res = await client.examListApi({
course_id: String(courseId),
page: 0,
record_type: "/exam",
});
if (requestToken !== recordRequestToken) return;
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as ExamListItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setExamList(list);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setWorkExamCache(cacheKey, list);
} else {
const res = await client.recordApi({
course_id: String(courseId),
page: 0,
record_type: nextRecordType,
});
if (requestToken !== recordRequestToken) {
return;
}
const rawList = (res as { data?: { list?: unknown } })?.data?.list;
const list = (Array.isArray(rawList) ? rawList : []) as RecordItem[];
const snapshot = accountStore.getState();
if (
snapshot.selectedAccountId !== accountId ||
snapshot.selectedCourseId !== courseId ||
snapshot.recordType !== nextRecordType
) {
return;
}
accountStore.getState().setRecords(list);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
accountStore
.getState()
.setRecordCache(cacheKey, list);
}
} catch (error) {
if (requestToken !== recordRequestToken) {
return;
@@ -602,6 +658,8 @@ const Account = () => {
error instanceof Error ? error.message : "获取记录失败,请稍后重试。";
setRecordError(message);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
} finally {
if (requestToken === recordRequestToken) {
setRecordsLoading(false);
@@ -665,6 +723,7 @@ const Account = () => {
try {
accountStore.getState().setAccountRunningStudy(account.id, true);
touchStudyHeartbeat(account.id);
startSilentAudio();
appendStudyLog(`开始刷课:${course.name}`, account.id);
await runStudyQueue({
accountId: account.id,
@@ -677,6 +736,7 @@ const Account = () => {
setIsRunningStudy: () => {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
},
onLog: (message: string, accoundID: string) => {
touchStudyHeartbeat(accoundID);
@@ -694,6 +754,7 @@ const Account = () => {
} finally {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
}
};
@@ -705,6 +766,7 @@ const Account = () => {
accountStore.getState().setAccountRunningStudy(account.id, false);
clearStudyHeartbeat(account.id);
stopSilentAudio();
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
};
@@ -729,10 +791,23 @@ const Account = () => {
return;
}
const cachedRecords =
recordCacheMap()[createRecordCacheKey(accountId, courseId, type)] ??
[];
accountStore.getState().setRecords(cachedRecords);
const cacheKey = createRecordCacheKey(accountId, courseId, type);
if (type === "/work" || type === "/exam") {
const cachedData = workExamCacheMap()[cacheKey] ?? [];
if (type === "/work") {
accountStore.getState().setWorkList(cachedData as WorkListItem[]);
accountStore.getState().setExamList([]);
} else {
accountStore.getState().setExamList(cachedData as ExamListItem[]);
accountStore.getState().setWorkList([]);
}
accountStore.getState().setRecords([]);
} else {
const cachedRecords = recordCacheMap()[cacheKey] ?? [];
accountStore.getState().setRecords(cachedRecords);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}
},
),
);
@@ -744,12 +819,19 @@ const Account = () => {
const cacheKey = account
? createRecordCacheKey(account.id, courseId ?? 0, type)
: "";
const hasCachedRecords = cacheKey ? cacheKey in recordCacheMap() : false;
const hasCachedRecords = cacheKey
? cacheKey in recordCacheMap() || cacheKey in workExamCacheMap()
: false;
if (!hasRestoredRecords()) {
setHasRestoredRecords(true);
if (courseId && account && (records().length > 0 || hasCachedRecords)) {
const hasAnyData =
records().length > 0 ||
workList().length > 0 ||
examList().length > 0 ||
hasCachedRecords;
if (courseId && account && hasAnyData) {
return;
}
}
@@ -818,6 +900,8 @@ const Account = () => {
recordTypeOptions={recordTypeOptions}
courseRecordTypeOptions={statusOptions}
records={records()}
workList={workList()}
examList={examList()}
studyLogs={studyLogs()}
recordsLoading={recordsLoading()}
recordError={recordError()}
@@ -840,6 +924,8 @@ const Account = () => {
accountStore.getState().setCourseKind(value);
accountStore.getState().setSelectedCourseId(null);
accountStore.getState().setRecords([]);
accountStore.getState().setWorkList([]);
accountStore.getState().setExamList([]);
}}
onStartStudy={() => void handleStartStudy()}
onStopStudy={handleStopStudy}

View File

@@ -469,7 +469,7 @@ const DebugLogs = () => {
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={connectDebugSocket}
disabled={
debugSocketState() === "open" ||
@@ -480,21 +480,21 @@ const DebugLogs = () => {
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={() => void loadDebugSnapshot()}
>
</button>
<button
type="button"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20"
class="rounded-full border border-cyan-300/25 bg-cyan-400/10 px-3 py-1.5 text-xs font-medium text-cyan-100 transition hover:bg-cyan-400/20 active:bg-cyan-400/30"
onClick={downloadDebugLogs}
>
</button>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-200 transition hover:bg-white/10 active:bg-white/20"
onClick={disconnectDebugSocket}
disabled={debugSocketState() !== "open"}
>
@@ -502,7 +502,7 @@ const DebugLogs = () => {
</button>
<button
type="button"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20"
class="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1.5 text-xs font-medium text-rose-100 transition hover:bg-rose-400/20 active:bg-rose-400/30"
onClick={clearDebugLogs}
>
@@ -543,7 +543,7 @@ const DebugLogs = () => {
class={`grid w-full grid-cols-[108px_88px_92px_minmax(240px,1fr)_minmax(220px,1fr)] items-start gap-3 rounded-[16px] border px-3 py-2 text-left transition ${
selectedDebugEntryId() === entry.id
? "border-cyan-300/35 bg-cyan-400/10"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6"
: "border-transparent bg-white/3 hover:border-white/10 hover:bg-white/6 active:bg-white/10"
}`}
onClick={() => setSelectedDebugEntryId(entry.id)}
>
@@ -590,7 +590,7 @@ const DebugLogs = () => {
class={`rounded-full border px-3 py-1.5 text-xs transition ${
selectedDetailTab() === tab.id
? "border-cyan-300/35 bg-cyan-400/12 text-cyan-100"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10"
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10 active:bg-white/20"
}`}
onClick={() => setSelectedDetailTab(tab.id)}
>

View File

@@ -198,7 +198,7 @@ const Logs = () => {
</Show>
<button
type="button"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50"
class="rounded-full border border-rose-200 bg-[linear-gradient(135deg,rgba(255,241,242,0.95),rgba(255,255,255,0.9))] px-3.5 py-1.5 text-sm font-medium text-rose-600 transition hover:-translate-y-px hover:bg-rose-50 active:bg-rose-100 active:scale-[0.97]"
onClick={clearAllLogs}
>

View File

@@ -1,4 +1,4 @@
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import { createMemo, createSignal, For, onCleanup, onMount } from "solid-js";
import { fetchDebugConfig, updateDebugConfig } from "~/service/debugLog";
import { hostApi } from "~/service/wk";
import { getMergedHosts, settingsStore } from "~/store/settings";
@@ -117,414 +117,367 @@ const Setting = () => {
void refreshDebugConfig();
});
const panelClass =
"rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]";
const sectionCardClass = "rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4";
return (
<div class="flex h-full min-h-0 flex-col overflow-hidden">
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(135deg,rgba(255,255,255,0.92),rgba(240,249,255,0.96))] px-5 py-4 shadow-[0_18px_45px_-28px_rgba(15,23,42,0.18)]">
<div class="flex shrink-0 items-center justify-between gap-4 rounded-[28px] border border-white/80 bg-[linear-gradient(125deg,rgba(255,255,255,0.96),rgba(240,249,255,0.95)_55%,rgba(236,254,255,0.9))] px-6 py-5 shadow-[0_22px_50px_-32px_rgba(15,23,42,0.22)]">
<div>
<p class="text-xs font-medium tracking-[0.26em] text-cyan-700/75 uppercase">
Settings Center
<p class="text-xs font-semibold tracking-[0.28em] text-cyan-700/80 uppercase">
Preference Center
</p>
<h1 class="mt-2 text-2xl font-semibold text-zinc-900"></h1>
<p class="mt-1 text-sm text-zinc-500">
Host
Host
</p>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white/90 px-4 py-3 text-right shadow-sm">
<p class="text-sm font-medium text-zinc-800"></p>
<p class="mt-1 text-xs text-zinc-500">
<div class="rounded-2xl border border-zinc-200/80 bg-white/90 px-4 py-3 text-right shadow-sm">
<p class="text-sm font-medium text-zinc-800"></p>
<p class="mt-1 text-xs leading-5 text-zinc-500">
Host
</p>
</div>
</div>
<div class="mt-4 grid min-h-0 flex-1 gap-4 xl:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]">
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
<div class="flex items-center justify-between gap-4 border-b border-zinc-200 pb-4">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<div class="mt-5 min-h-0 flex-1 overflow-hidden">
<div class="grid h-full min-h-full w-full gap-5 lg:grid-cols-2">
<div class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<section class={panelClass}>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 bg-rose-50 px-3 py-1.5 text-xs text-rose-600 transition hover:bg-rose-100 active:bg-rose-200"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-4 grid gap-3">
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
</div>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-zinc-900"></p>
<p class="mt-1 text-xs text-zinc-500"></p>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200 bg-white px-2.5 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200"
onClick={() =>
settingsStore.getState().clearPersistedSection("logs")
}
>
</button>
</div>
</div>
</div>
</section>
<section class={panelClass}>
<div class="border-b border-zinc-200/70 pb-4">
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
使
</p>
</div>
<button
type="button"
class="rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-600 transition hover:bg-rose-100"
onClick={() => settingsStore.getState().clearAllPersistedData()}
>
</button>
</div>
<div class="mt-4 grid gap-4">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
<div class="mt-4 grid gap-4 md:grid-cols-2">
<div class={`${sectionCardClass} md:col-span-2`}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
/ SSL
</p>
</div>
<input
type="checkbox"
checked={state().debugEnabled}
disabled={debugSyncing()}
onChange={(event) =>
void handleDebugToggle(event.currentTarget.checked)
}
/>
</div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600">
<p>{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p>
<p class="mt-1">{backendDebugState()?.buildMode ?? "-"}</p>
<p class="mt-1">
{backendDebugState()?.proxyConfigured
? backendDebugState()?.proxy
: "未配置"}
</p>
<p class="mt-1">
SSL
{backendDebugState()?.skipSSLVerify ? "已配置" : "未配置"}
</p>
</div>
<input
type="checkbox"
checked={state().persistAccounts}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection(
"accounts",
event.currentTarget.checked,
)
}
/>
{debugError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{debugError()}
</div>
) : null}
</div>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
onClick={() =>
settingsStore.getState().clearPersistedSection("accounts")
}
>
</button>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
</div>
<input
type="checkbox"
checked={state().autoScrollLogs}
onChange={(event) =>
settingsStore
.getState()
.setAutoScrollLogs(event.currentTarget.checked)
}
/>
</div>
<input
type="checkbox"
checked={state().persistRecords}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection(
"records",
event.currentTarget.checked,
)
}
/>
</div>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
onClick={() =>
settingsStore.getState().clearPersistedSection("records")
}
>
</button>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
<div class={sectionCardClass}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
</div>
<input
type="checkbox"
checked={state().showLogTimestamps}
onChange={(event) =>
settingsStore
.getState()
.setShowLogTimestamps(event.currentTarget.checked)
}
/>
</div>
<input
type="checkbox"
checked={state().persistLogs}
onChange={(event) =>
settingsStore
.getState()
.setPersistSection("logs", event.currentTarget.checked)
}
/>
</div>
<button
type="button"
class="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100"
onClick={() =>
settingsStore.getState().clearPersistedSection("logs")
}
>
</button>
<div class={sectionCardClass}>
<label class="block">
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500"></p>
<select
class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={state().densityMode}
onChange={(event) =>
settingsStore
.getState()
.setDensityMode(
event.currentTarget.value as
| "comfortable"
| "compact",
)
}
>
<option value="comfortable"></option>
<option value="compact"></option>
</select>
</label>
</div>
<div class={sectionCardClass}>
<label class="block">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">{state().logFontSize}px</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="11"
max="16"
value={state().logFontSize}
onInput={(event) =>
settingsStore
.getState()
.setLogFontSize(Number(event.currentTarget.value))
}
/>
</label>
</div>
<div class={`${sectionCardClass} md:col-span-2`}>
<label class="block">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">{state().sidebarWidth}px</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="280"
max="380"
step="10"
value={state().sidebarWidth}
onInput={(event) =>
settingsStore
.getState()
.setSidebarWidth(Number(event.currentTarget.value))
}
/>
</label>
</div>
</div>
</div>
</section>
</div>
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
<div class="border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
使
</p>
</div>
<div class="flex min-h-0 h-full flex-col gap-5 overflow-y-auto pr-1">
<section class={panelClass}>
<div class="border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="mt-1 text-sm text-zinc-500">
Host Host
</p>
</div>
<div class="mt-4 grid gap-4">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class={`${sectionCardClass} mt-4`}>
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
/ SSL
</p>
</div>
<p class="font-medium text-zinc-900"> Host</p>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 active:bg-zinc-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingRemoteHosts()}
onClick={() => void loadRemoteHosts()}
>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input
type="checkbox"
checked={state().debugEnabled}
disabled={debugSyncing()}
onChange={(event) =>
void handleDebugToggle(event.currentTarget.checked)
}
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostLabel()}
onInput={(event) => setHostLabel(event.currentTarget.value)}
placeholder="名称,如:校内测试"
/>
<input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostValue()}
onInput={(event) => setHostValue(event.currentTarget.value)}
placeholder="Hostexample.com"
/>
</div>
<div class="mt-3 rounded-xl border border-zinc-200 bg-white px-4 py-3 text-sm text-zinc-600">
<p>{backendDebugState()?.enabled ? "已开启" : "已关闭"}</p>
<p class="mt-1">
{backendDebugState()?.buildMode ?? "-"}
</p>
<p class="mt-1">
{backendDebugState()?.proxyConfigured
? backendDebugState()?.proxy
: "未配置"}
</p>
<p class="mt-1">
SSL
{backendDebugState()?.skipSSLVerify ? "已配置" : "未配置"}
</p>
</div>
{debugError() ? (
<button
type="button"
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600 active:bg-cyan-700"
onClick={addLocalHost}
>
Host
</button>
{hostError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{debugError()}
{hostError()}
</div>
) : null}
</div>
</section>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
</div>
<input
type="checkbox"
checked={state().autoScrollLogs}
onChange={(event) =>
settingsStore
.getState()
.setAutoScrollLogs(event.currentTarget.checked)
}
/>
</div>
<section class={panelClass}>
<div class="flex items-center justify-between border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="text-xs text-zinc-500"></p>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
</div>
<input
type="checkbox"
checked={state().showLogTimestamps}
onChange={(event) =>
settingsStore
.getState()
.setShowLogTimestamps(event.currentTarget.checked)
}
/>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<label class="block">
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
</p>
<select
class="mt-3 w-full rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={state().densityMode}
onChange={(event) =>
settingsStore
.getState()
.setDensityMode(
event.currentTarget.value as
| "comfortable"
| "compact",
)
}
>
<option value="comfortable"></option>
<option value="compact"></option>
</select>
</label>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<label class="block">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
{state().logFontSize}px
</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="11"
max="16"
value={state().logFontSize}
onInput={(event) =>
settingsStore
.getState()
.setLogFontSize(Number(event.currentTarget.value))
}
/>
</label>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<label class="block">
<div class="flex items-center justify-between gap-3">
<div>
<p class="font-medium text-zinc-900"></p>
<p class="mt-1 text-sm text-zinc-500">
{state().sidebarWidth}px
</p>
</div>
</div>
<input
class="mt-4 w-full"
type="range"
min="280"
max="380"
step="10"
value={state().sidebarWidth}
onInput={(event) =>
settingsStore
.getState()
.setSidebarWidth(Number(event.currentTarget.value))
}
/>
</label>
</div>
</div>
</div>
</section>
<section class="flex min-h-0 flex-col gap-4 overflow-y-auto pr-1">
<div class="rounded-[28px] border border-white/80 bg-white/85 p-5 shadow-[0_18px_50px_-28px_rgba(15,23,42,0.25)]">
<div class="border-b border-zinc-200 pb-4">
<p class="text-lg font-semibold text-zinc-900">Host </p>
<p class="mt-1 text-sm text-zinc-500">
Host Host
</p>
</div>
<div class="mt-4 rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<div class="flex items-center justify-between gap-3">
<p class="font-medium text-zinc-900"> Host</p>
<button
type="button"
class="rounded-xl border border-zinc-200 bg-white px-3 py-2 text-sm text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isLoadingRemoteHosts()}
onClick={() => void loadRemoteHosts()}
>
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
</button>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostLabel()}
onInput={(event) => setHostLabel(event.currentTarget.value)}
placeholder="名称,如:校内测试"
/>
<input
class="rounded-xl border border-zinc-200 bg-white px-4 py-3 transition outline-none focus:border-cyan-400"
value={hostValue()}
onInput={(event) => setHostValue(event.currentTarget.value)}
placeholder="Hostexample.com"
/>
</div>
<button
type="button"
class="mt-3 rounded-xl bg-cyan-500 px-4 py-2 text-sm text-white transition hover:bg-cyan-600"
onClick={addLocalHost}
>
Host
</button>
{hostError() ? (
<div class="mt-3 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{hostError()}
</div>
) : null}
</div>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<p class="font-medium text-zinc-900"> Host</p>
<div class="mt-3 flex flex-col gap-3">
<For each={state().localHosts}>
{(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-zinc-900">
{item.label}
</p>
<p class="mt-1 text-sm text-zinc-500">
{item.host}
</p>
<div class="mt-4 grid gap-4 xl:grid-cols-2">
<div class={sectionCardClass}>
<p class="font-medium text-zinc-900"> Host</p>
<div class="mt-3 flex flex-col gap-3">
<For each={state().localHosts}>
{(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<div class="flex items-start justify-between gap-3">
<div>
<p class="font-medium text-zinc-900">{item.label}</p>
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50 active:bg-rose-100"
onClick={() =>
settingsStore.getState().removeLocalHost(item.host)
}
>
</button>
</div>
<button
type="button"
class="rounded-lg border border-rose-200 px-3 py-1 text-xs text-rose-600 transition hover:bg-rose-50"
onClick={() =>
settingsStore
.getState()
.removeLocalHost(item.host)
}
>
</button>
</div>
</div>
)}
</For>
)}
</For>
</div>
</div>
</div>
<div class="rounded-2xl border border-zinc-200 bg-zinc-50/80 p-4">
<p class="font-medium text-zinc-900"></p>
<div class="mt-3 flex flex-col gap-3">
<For each={mergedHosts()}>
{(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<p class="font-medium text-zinc-900">{item.label}</p>
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
<p class="mt-2 text-xs text-cyan-700">
{item.source === "local" ? "本地优先" : "远端"}
</p>
</div>
)}
</For>
<div class={sectionCardClass}>
<p class="font-medium text-zinc-900"></p>
<div class="mt-3 flex flex-col gap-3">
<For each={mergedHosts()}>
{(item) => (
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-3">
<p class="font-medium text-zinc-900">{item.label}</p>
<p class="mt-1 text-sm text-zinc-500">{item.host}</p>
<p class="mt-2 text-xs text-cyan-700">
{item.source === "local" ? "本地优先" : "远端"}
</p>
</div>
)}
</For>
</div>
</div>
</div>
</div>
</section>
</div>
</section>
</div>
</div>
</div>
);

View File

@@ -0,0 +1,57 @@
/**
* Silent audio playback to prevent browser tab throttling during long-running tasks.
*
* Uses the Web Audio API to produce a nearly inaudible signal that keeps the
* browser from suspending the tab's timers and network requests.
*/
let audioContext: AudioContext | null = null;
let oscillatorNode: OscillatorNode | null = null;
let gainNode: GainNode | null = null;
/**
* Start playing silent audio. Safe to call multiple times — duplicate calls
* are ignored if audio is already playing.
*/
export const startSilentAudio = () => {
if (oscillatorNode) {
return;
}
try {
audioContext = new AudioContext();
gainNode = audioContext.createGain();
gainNode.gain.value = 0.001; // Nearly silent
oscillatorNode = audioContext.createOscillator();
oscillatorNode.type = "sine";
oscillatorNode.frequency.value = 1; // Sub-bass, inaudible
oscillatorNode.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillatorNode.start();
} catch {
// AudioContext may be unavailable in some environments; degrade silently.
oscillatorNode = null;
gainNode = null;
audioContext = null;
}
};
/**
* Stop playing silent audio and release resources.
*/
export const stopSilentAudio = () => {
try {
oscillatorNode?.stop();
} catch {
// Already stopped or never started
}
oscillatorNode?.disconnect();
gainNode?.disconnect();
audioContext?.close();
oscillatorNode = null;
gainNode = null;
audioContext = null;
};

View File

@@ -1,6 +1,5 @@
import type { Accessor } from "solid-js";
import http, {
DEFAULT_HTTP_TIMEOUT_MS,
createHttpClient,
type HttpClient,
} from "~/service/http";
@@ -13,6 +12,80 @@ export type RecordType = "" | "/work" | "/exam" | "/discuss";
export type StudyStatus = 1 | 2 | 3;
export type WorkListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
type: string;
remarks: string;
addTime: string;
sequence: string;
nodeId: string;
courseId: string;
startTime: string;
endTime: string;
paperId: string;
createUserId: string;
isPrivate: string;
classList: string;
teacherType: string;
allow: string;
frequency: string;
scoringRules: string;
hasCollect: string;
lock: string | number | null;
schoolId: string;
parsing: string;
addDate: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
typeName: string;
finishTime: string;
url: string;
};
export type ExamListItem = {
id: string;
userId: string | number | null;
title: string;
topicNumber: string;
score: string;
addTime: string;
nodeId: string;
courseId: string;
limitedTime: string;
sequence: string;
remarks: string;
paperId: string;
startTime: string;
endTime: string;
createUserId: string;
classList: string;
isPrivate: string;
teacherType: string;
allow: string;
frequency: string;
hasCollect: string;
schoolId: string;
parsing: string;
addDate: string;
random: string;
randData: unknown;
randNumber: string;
name: string;
chapterId: string;
state: string;
submitTime: string;
finalScore: string;
finishTime: string;
url: string;
};
type ApiResponse<T> = {
code: number;
message: string;
@@ -165,16 +238,33 @@ export type StudyRunnerPayload = {
onLog?: (message: string, accoundID: string) => void;
};
export type WorkListData = {
list: WorkListItem[];
page_info: PageInfo;
};
export type WorkListRes = ApiResponse<WorkListData>;
export type ExamListData = {
list: ExamListItem[];
page_info: PageInfo;
};
export type ExamListRes = ApiResponse<ExamListData>;
export type WkClient = {
userInfoApi: () => Promise<UserInfoRes>;
courseApi: (payload: CourseReq) => Promise<CourseRes>;
recordApi: (payload: RecordReq) => Promise<RecordRes>;
workListApi: (payload: RecordReq) => Promise<WorkListRes>;
examListApi: (payload: RecordReq) => Promise<ExamListRes>;
studyApi: (payload: StudyReq) => Promise<StudyRes>;
logoutApi: () => Promise<LogoutRes>;
};
const RECORD_API_TIMEOUT_MS = 60000;
const COURSE_API_TIMEOUT_MS = Math.max(DEFAULT_HTTP_TIMEOUT_MS, 30000);
// Course list can be slow on large accounts, use a longer timeout than default
const COURSE_API_TIMEOUT_MS = 30000;
export const loginApi = async (payload: LoginReq) => {
const res = await http.post<LoginRes>("/api/login", payload);
@@ -195,6 +285,22 @@ const createWkClientFromHttp = (client: HttpClient): WkClient => ({
timeout: RECORD_API_TIMEOUT_MS,
});
},
workListApi(payload) {
return client.post<WorkListRes>("/api/v2/record", {
...payload,
record_type: "/work",
}, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
examListApi(payload) {
return client.post<ExamListRes>("/api/v2/record", {
...payload,
record_type: "/exam",
}, {
timeout: RECORD_API_TIMEOUT_MS,
});
},
studyApi(payload) {
return client.post<StudyRes>("/api/v2/study", payload);
},
@@ -313,7 +419,7 @@ export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
continue;
}
if (resp.data.state != 0) {
if (resp.data.state !== 0) {
_payload.onLog?.(`⛔ 自动停止: ${resp.data.msg}`, _payload.accountId);
_payload.setIsRunningStudy();
return;

View File

@@ -1,6 +1,6 @@
import { createStore } from "zustand/vanilla";
import { persist, createJSONStorage } from "zustand/middleware";
import type { CourseKind, RecordItem, RecordType } from "~/service/wk";
import type { CourseKind, RecordItem, RecordType, WorkListItem, ExamListItem } from "~/service/wk";
import type { CourseType } from "~/types/Course";
import type { userInfoType } from "~/types/Userinfo";
@@ -39,6 +39,8 @@ export type AccountItem = {
export type RecordCacheMap = Record<string, RecordItem[]>;
export type WorkExamCacheMap = Record<string, WorkListItem[] | ExamListItem[]>;
type PersistPreferences = {
persistAccounts: boolean;
persistRecords: boolean;
@@ -87,6 +89,9 @@ type AccountState = {
recordType: RecordType;
records: RecordItem[];
recordCacheMap: RecordCacheMap;
workList: WorkListItem[];
examList: ExamListItem[];
workExamCacheMap: WorkExamCacheMap;
studyLogsMap: Record<string, string[]>;
runningStudyMap: Record<string, boolean>;
studyHeartbeatMap: Record<string, number>;
@@ -97,6 +102,9 @@ type AccountState = {
setRecordType: (recordType: RecordType) => void;
setRecords: (records: RecordItem[]) => void;
setRecordCache: (cacheKey: string, records: RecordItem[]) => void;
setWorkList: (list: WorkListItem[]) => void;
setExamList: (list: ExamListItem[]) => void;
setWorkExamCache: (cacheKey: string, data: WorkListItem[] | ExamListItem[]) => void;
setAccountRunningStudy: (accountId: string, value: boolean) => void;
touchStudyHeartbeat: (accountId: string, timestamp?: number) => void;
clearStudyHeartbeat: (accountId: string) => void;
@@ -107,6 +115,9 @@ type AccountState = {
upsertAccount: (account: AccountItem) => void;
setAccountCourses: (accountId: string, courses: CourseType[]) => void;
removeAccount: (accountId: string) => void;
clearAllData: () => void;
clearRecordsData: () => void;
clearAccountsData: () => void;
};
export const accountStore = createStore<AccountState>()(
@@ -120,6 +131,9 @@ export const accountStore = createStore<AccountState>()(
recordType: "",
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
studyLogsMap: {},
runningStudyMap: {},
studyHeartbeatMap: {},
@@ -138,6 +152,15 @@ export const accountStore = createStore<AccountState>()(
[cacheKey]: records,
},
})),
setWorkList: (list) => set({ workList: list }),
setExamList: (list) => set({ examList: list }),
setWorkExamCache: (cacheKey, data) =>
set((state) => ({
workExamCacheMap: {
...state.workExamCacheMap,
[cacheKey]: data,
},
})),
setAccountRunningStudy: (accountId, value) =>
set((state) => ({
runningStudyMap: {
@@ -189,6 +212,39 @@ export const accountStore = createStore<AccountState>()(
set({
studyLogsMap: {},
}),
clearAllData: () =>
set({
accounts: [],
selectedAccountId: "",
expandedAccountId: "",
selectedCourseId: null,
courseKind: "run" as CourseKind,
recordType: "" as RecordType,
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
studyLogsMap: {},
runningStudyMap: {},
studyHeartbeatMap: {},
}),
clearRecordsData: () =>
set({
records: [],
recordCacheMap: {},
workList: [],
examList: [],
workExamCacheMap: {},
selectedCourseId: null,
}),
clearAccountsData: () =>
set({
accounts: [],
selectedAccountId: "",
expandedAccountId: "",
selectedCourseId: null,
}),
upsertAccount: (account) =>
set((state) => ({
accounts: [

View File

@@ -1,5 +1,6 @@
import { createStore } from "zustand/vanilla";
import { createJSONStorage, persist } from "zustand/middleware";
import { accountStore } from "~/store/account";
export type HostOption = {
label: string;
@@ -37,6 +38,7 @@ type SettingsState = {
};
const accountStorageKey = "account-storage";
const settingsStorageKey = "settings-storage";
type PersistedStorage = {
state?: Record<string, unknown>;
version?: number;
@@ -115,53 +117,40 @@ export const settingsStore = createStore<SettingsState>()(
}
},
clearPersistedSection: (section) => {
const store = accountStore.getState();
if (section === "accounts") {
patchAccountStorage((state) => {
const {
accounts,
selectedAccountId,
expandedAccountId,
selectedCourseId,
records,
recordCacheMap,
studyLogsMap,
runningStudyMap,
...rest
} = state;
void accounts;
void selectedAccountId;
void expandedAccountId;
void selectedCourseId;
void records;
void recordCacheMap;
void studyLogsMap;
void runningStudyMap;
return rest;
});
store.clearAccountsData();
localStorage.removeItem(accountStorageKey);
}
if (section === "records") {
store.clearRecordsData();
patchAccountStorage((state) => {
const { records, recordCacheMap, selectedCourseId, ...rest } =
state;
const { records, recordCacheMap, workList, examList, workExamCacheMap, ...rest } = state;
void records;
void recordCacheMap;
void selectedCourseId;
void workList;
void examList;
void workExamCacheMap;
return rest;
});
}
if (section === "logs") {
store.clearAllStudyLogs();
patchAccountStorage((state) => {
const { studyLogsMap, runningStudyMap, ...rest } = state;
const { studyLogsMap, ...rest } = state;
void studyLogsMap;
void runningStudyMap;
return rest;
});
}
},
clearAllPersistedData: () => {
accountStore.getState().clearAllData();
localStorage.removeItem(accountStorageKey);
localStorage.removeItem(settingsStorageKey);
window.location.reload();
},
setDebugEnabled: (value) => set({ debugEnabled: value }),
setAutoScrollLogs: (value) => set({ autoScrollLogs: value }),