Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e491d4e60 | |||
| 6bd5c0973f | |||
| a182c64f82 | |||
| 0c0d2a0292 | |||
| 5d4e0f493c | |||
| a1911573d1 | |||
| 13f0be162b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
doc
|
||||
|
||||
# Claude Code
|
||||
.claude/
|
||||
|
||||
16
codestable/architecture/ARCHITECTURE.md
Normal file
16
codestable/architecture/ARCHITECTURE.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 刷课系统前端 架构总入口
|
||||
|
||||
> 状态:骨架(待填充)
|
||||
> 创建日期:2026-04-25
|
||||
|
||||
## 1. 项目简介
|
||||
|
||||
刷课系统前端——基于 SolidJS + Vite + Tailwind CSS + Zustand 构建的前端应用,提供账号管理、课程列表、日志查看、系统设置等功能。
|
||||
|
||||
## 2. 核心概念 / 术语表
|
||||
|
||||
## 3. 子系统 / 模块索引
|
||||
|
||||
## 4. 关键架构决定
|
||||
|
||||
## 5. 已知约束 / 硬边界
|
||||
0
codestable/compound/.gitkeep
Normal file
0
codestable/compound/.gitkeep
Normal file
0
codestable/features/.gitkeep
Normal file
0
codestable/features/.gitkeep
Normal file
0
codestable/issues/.gitkeep
Normal file
0
codestable/issues/.gitkeep
Normal file
97
codestable/reference/code-dimensions.md
Normal file
97
codestable/reference/code-dimensions.md
Normal 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 但代码里外部输入没校验,就是不达标。
|
||||
50
codestable/reference/maintainer-notes.md
Normal file
50
codestable/reference/maintainer-notes.md
Normal 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/`,不要散落在各子技能里
|
||||
88
codestable/reference/requirement-example.md
Normal file
88
codestable/reference/requirement-example.md
Normal 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 里删掉。
|
||||
286
codestable/reference/shared-conventions.md
Normal file
286
codestable/reference/shared-conventions.md
Normal 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`(落盘即确认,无 draft);design = `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` 之外保留自己的专属 frontmatter(learning 的 `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 的推进计划,要么记成顺手发现留到后续。
|
||||
|
||||
不许偷偷拆完继续写,也不许忽略信号硬冲。默认动作是停、问、再继续。
|
||||
112
codestable/reference/system-overview.md
Normal file
112
codestable/reference/system-overview.md
Normal 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` — 项目架构总入口
|
||||
98
codestable/reference/tools.md
Normal file
98
codestable/reference/tools.md
Normal 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
|
||||
```
|
||||
0
codestable/requirements/.gitkeep
Normal file
0
codestable/requirements/.gitkeep
Normal file
0
codestable/roadmap/.gitkeep
Normal file
0
codestable/roadmap/.gitkeep
Normal file
314
codestable/tools/search-yaml.py
Normal file
314
codestable/tools/search-yaml.py
Normal 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()
|
||||
312
codestable/tools/validate-yaml.py
Normal file
312
codestable/tools/validate-yaml.py
Normal 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()
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
159
src/App.tsx
159
src/App.tsx
@@ -6,10 +6,12 @@ import {
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
onMount,
|
||||
onCleanup,
|
||||
} from "solid-js";
|
||||
import { A, useLocation } from "@solidjs/router";
|
||||
import Dialog from "~/components/dialog/Dialog";
|
||||
import { updateDebugConfig } from "~/service/debugLog";
|
||||
import {
|
||||
RELEASES_PAGE_URL,
|
||||
type LatestRelease,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
resolveReleaseLink,
|
||||
} from "~/service/update";
|
||||
import { versionApi } from "~/service/wk";
|
||||
import { settingsStore } from "~/store/settings";
|
||||
|
||||
type DownloadState = "idle" | "downloading" | "done" | "error";
|
||||
type UpdateCheckState =
|
||||
@@ -134,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");
|
||||
@@ -155,7 +155,9 @@ const App: ParentComponent = (props) => {
|
||||
const [downloadState, setDownloadState] = createSignal<DownloadState>("idle");
|
||||
const [downloadProgress, setDownloadProgress] = createSignal(0);
|
||||
const [downloadError, setDownloadError] = createSignal("");
|
||||
const [settingsState, setSettingsState] = createSignal(settingsStore.getState());
|
||||
let updateAbortController: AbortController | null = null;
|
||||
let lastDebugSyncValue: boolean | undefined;
|
||||
|
||||
const isActive = (url: string) =>
|
||||
location.pathname === url ||
|
||||
@@ -170,9 +172,7 @@ const App: ParentComponent = (props) => {
|
||||
const authorText = createMemo(() => version()?.data.GitAuthor ?? "unknown");
|
||||
const emailText = createMemo(() => version()?.data.GitEmail ?? "unknown");
|
||||
const modeText = createMemo(() => version()?.data.Mode ?? "unknown");
|
||||
const isDebugMode = createMemo(
|
||||
() => modeText().toLowerCase() === "debug",
|
||||
);
|
||||
const isDebugMode = createMemo(() => settingsState().debugEnabled);
|
||||
const asideList = createMemo(() => {
|
||||
const items = [
|
||||
{ label: "账号", url: "/account" },
|
||||
@@ -194,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 ?? ""),
|
||||
);
|
||||
@@ -228,17 +203,25 @@ const App: ParentComponent = (props) => {
|
||||
const releaseLink = createMemo(
|
||||
() => latestRelease()?.html_url || RELEASES_PAGE_URL,
|
||||
);
|
||||
|
||||
const handleCopyVersion = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(versionPayloadText());
|
||||
setCopyState("done");
|
||||
} catch {
|
||||
setCopyState("error");
|
||||
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) => {
|
||||
setSettingsState(state);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
window.setTimeout(() => setCopyState("idle"), 1800);
|
||||
};
|
||||
const performUpdateCheck = async (manual = false) => {
|
||||
if (updateCheckState() === "checking") {
|
||||
return;
|
||||
@@ -263,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) {
|
||||
@@ -322,6 +305,17 @@ const App: ParentComponent = (props) => {
|
||||
void performUpdateCheck(false);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const enabled = settingsState().debugEnabled;
|
||||
if (lastDebugSyncValue === enabled) {
|
||||
return;
|
||||
}
|
||||
lastDebugSyncValue = enabled;
|
||||
void updateDebugConfig(enabled).catch(() => {
|
||||
// Keep the local preference and let settings page surface sync errors.
|
||||
});
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
if (updateAbortController) {
|
||||
updateAbortController.abort();
|
||||
@@ -376,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">
|
||||
@@ -401,45 +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-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}
|
||||
@@ -461,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)}
|
||||
>
|
||||
@@ -476,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()}
|
||||
>
|
||||
|
||||
@@ -14,11 +14,11 @@ interface AccountSidebarProps {
|
||||
statusOptions: StatusOption[];
|
||||
currentCourseKind: CourseKind;
|
||||
hostLabels: Record<string, string>;
|
||||
isRefreshingAccount: boolean;
|
||||
refreshingAccountId: string;
|
||||
loggingOutId: string;
|
||||
densityMode: "comfortable" | "compact";
|
||||
sidebarWidth: number;
|
||||
onRefreshAccount: () => void;
|
||||
onRefreshAccount: (accountId: string) => void;
|
||||
onSelectAccount: (accountId: string) => void;
|
||||
onToggleExpand: (accountId: string) => void;
|
||||
onLogout: (accountId: string) => void;
|
||||
@@ -42,15 +42,6 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
||||
选择账号后查看课程与记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="self-start rounded-xl border border-zinc-200 bg-white px-3 py-1.5 text-xs text-zinc-700 transition hover:bg-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 sm:self-auto sm:text-sm"
|
||||
disabled={!props.selectedAccountId || props.isRefreshingAccount}
|
||||
onClick={props.onRefreshAccount}
|
||||
>
|
||||
{props.isRefreshingAccount ? "刷新中..." : "刷新账号"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -81,6 +72,7 @@ const AccountSidebar = (props: AccountSidebarProps) => {
|
||||
: `登录类型:${statusLabel}`;
|
||||
const badgeTypeLabel = selected() ? currentCourseLabel : statusLabel;
|
||||
const badgeCountLabel = `${account.courses.length} 门`;
|
||||
const isRefreshing = () => props.refreshingAccountId === account.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -97,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">
|
||||
@@ -127,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() ? "收起" : "展开"}
|
||||
@@ -147,14 +139,25 @@ 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-cyan-200 bg-white px-2.5 py-1 text-xs text-cyan-700 transition hover:bg-cyan-50"
|
||||
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();
|
||||
props.onRefreshAccount(account.id);
|
||||
}}
|
||||
>
|
||||
{isRefreshing() ? "刷新中..." : "刷新账号"}
|
||||
</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 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();
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
清空日志
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
>
|
||||
关闭
|
||||
|
||||
@@ -13,14 +13,18 @@ import AddAccountDialog, {
|
||||
import CourseWorkspace from "~/components/account/CourseWorkspace";
|
||||
import {
|
||||
createWkClient,
|
||||
createSessionWkClient,
|
||||
hostApi,
|
||||
loginApi,
|
||||
runStudyQueue,
|
||||
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";
|
||||
@@ -93,7 +97,7 @@ const Account = () => {
|
||||
const [showDialog, setShowDialog] = createSignal(false);
|
||||
const [isSubmitting, setIsSubmitting] = createSignal(false);
|
||||
const [loggingOutId, setLoggingOutId] = createSignal("");
|
||||
const [isRefreshingAccount, setIsRefreshingAccount] = createSignal(false);
|
||||
const [refreshingAccountId, setRefreshingAccountId] = createSignal("");
|
||||
const [errorMessage, setErrorMessage] = createSignal("");
|
||||
const [form, setForm] = createSignal<LoginForm>(
|
||||
createDefaultForm("cqcst.leykeji.com"),
|
||||
@@ -195,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(() =>
|
||||
@@ -300,6 +307,12 @@ const Account = () => {
|
||||
return client;
|
||||
};
|
||||
|
||||
const fetchUserInfoBySession = async (sessionId: string) => {
|
||||
const client = createSessionWkClient(sessionId);
|
||||
const res = await client.userInfoApi();
|
||||
return res.data.user;
|
||||
};
|
||||
|
||||
const reloginBySession = async (sessionId: string) => {
|
||||
if (!sessionId) {
|
||||
return false;
|
||||
@@ -321,14 +334,14 @@ const Account = () => {
|
||||
status: target.status,
|
||||
host: target.host,
|
||||
});
|
||||
const user = await fetchUserInfoBySession(res.data.session_id);
|
||||
|
||||
accountStore.getState().upsertAccount({
|
||||
replaceAccountPreservingView({
|
||||
...target,
|
||||
sessionId: res.data.session_id,
|
||||
user: res.data.user,
|
||||
courses: res.data.courses ?? target.courses,
|
||||
user,
|
||||
courses: target.courses,
|
||||
});
|
||||
await loadCourses(target.id, target.status);
|
||||
appendStudyLog(`会话失效,已重新登录:${target.user.name}`, target.id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -376,11 +389,11 @@ const Account = () => {
|
||||
status: payload.status,
|
||||
host: payload.host,
|
||||
});
|
||||
|
||||
const accountId = `${res.data.user.id}-${payload.host}-${payload.status}`;
|
||||
const user = await fetchUserInfoBySession(res.data.session_id);
|
||||
const accountId = `${user.id}-${payload.host}-${payload.status}`;
|
||||
const nextAccount: AccountItem = {
|
||||
id: accountId,
|
||||
username: payload.username.trim() || res.data.user.id,
|
||||
username: payload.username.trim() || user.id,
|
||||
host: payload.host,
|
||||
status: payload.status,
|
||||
sessionId: res.data.session_id,
|
||||
@@ -388,12 +401,11 @@ const Account = () => {
|
||||
password: payload.password.trim(),
|
||||
token: payload.token.trim(),
|
||||
},
|
||||
user: res.data.user,
|
||||
courses: res.data.courses ?? [],
|
||||
user,
|
||||
courses: [],
|
||||
};
|
||||
|
||||
accountStore.getState().upsertAccount(nextAccount);
|
||||
await loadCourses(nextAccount.id, nextAccount.status);
|
||||
accountStore.getState().setSelectedCourseId(null);
|
||||
accountStore.getState().setRecords([]);
|
||||
setShowDialog(false);
|
||||
@@ -420,6 +432,24 @@ const Account = () => {
|
||||
accountStore.getState().setExpandedAccountId(nextId);
|
||||
};
|
||||
|
||||
const replaceAccountPreservingView = (nextAccount: AccountItem) => {
|
||||
const snapshot = accountStore.getState();
|
||||
const previousSelectedAccountId = snapshot.selectedAccountId;
|
||||
const previousExpandedAccountId = snapshot.expandedAccountId;
|
||||
const previousSelectedCourseId = snapshot.selectedCourseId;
|
||||
|
||||
snapshot.upsertAccount(nextAccount);
|
||||
|
||||
if (previousSelectedAccountId !== nextAccount.id) {
|
||||
snapshot.setSelectedAccountId(previousSelectedAccountId);
|
||||
snapshot.setSelectedCourseId(previousSelectedCourseId);
|
||||
}
|
||||
|
||||
if (previousExpandedAccountId !== nextAccount.id) {
|
||||
snapshot.setExpandedAccountId(previousExpandedAccountId);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCourses = async (
|
||||
accountId = selectedAccount()?.id,
|
||||
status = courseKind(),
|
||||
@@ -465,14 +495,14 @@ const Account = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshAccount = async () => {
|
||||
const account = selectedAccount();
|
||||
const handleRefreshAccount = async (accountId: string) => {
|
||||
const account = accounts().find((item) => item.id === accountId);
|
||||
if (!account) {
|
||||
setErrorMessage("请先选择账号。");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRefreshingAccount(true);
|
||||
setRefreshingAccountId(accountId);
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
@@ -483,20 +513,21 @@ const Account = () => {
|
||||
status: account.status,
|
||||
host: account.host,
|
||||
});
|
||||
const user = await fetchUserInfoBySession(res.data.session_id);
|
||||
|
||||
accountStore.getState().upsertAccount({
|
||||
replaceAccountPreservingView({
|
||||
...account,
|
||||
sessionId: res.data.session_id,
|
||||
user: res.data.user,
|
||||
courses: res.data.courses ?? account.courses,
|
||||
user,
|
||||
courses: account.courses,
|
||||
});
|
||||
await loadCourses(account.id, account.status);
|
||||
await loadCourses(accountId, account.status);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "刷新账号失败,请稍后重试。";
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsRefreshingAccount(false);
|
||||
setRefreshingAccountId("");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -542,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;
|
||||
@@ -577,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);
|
||||
@@ -640,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,
|
||||
@@ -652,6 +736,7 @@ const Account = () => {
|
||||
setIsRunningStudy: () => {
|
||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||
clearStudyHeartbeat(account.id);
|
||||
stopSilentAudio();
|
||||
},
|
||||
onLog: (message: string, accoundID: string) => {
|
||||
touchStudyHeartbeat(accoundID);
|
||||
@@ -669,6 +754,7 @@ const Account = () => {
|
||||
} finally {
|
||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||
clearStudyHeartbeat(account.id);
|
||||
stopSilentAudio();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -680,6 +766,7 @@ const Account = () => {
|
||||
|
||||
accountStore.getState().setAccountRunningStudy(account.id, false);
|
||||
clearStudyHeartbeat(account.id);
|
||||
stopSilentAudio();
|
||||
appendStudyLog(`已发送停止刷课指令:${account.user.name}`, account.id);
|
||||
};
|
||||
|
||||
@@ -704,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([]);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -719,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;
|
||||
}
|
||||
}
|
||||
@@ -771,11 +878,11 @@ const Account = () => {
|
||||
statusOptions={statusOptions}
|
||||
currentCourseKind={courseKind()}
|
||||
hostLabels={hostLabels()}
|
||||
isRefreshingAccount={isRefreshingAccount()}
|
||||
refreshingAccountId={refreshingAccountId()}
|
||||
loggingOutId={loggingOutId()}
|
||||
densityMode={settingsState().densityMode}
|
||||
sidebarWidth={settingsState().sidebarWidth}
|
||||
onRefreshAccount={() => void handleRefreshAccount()}
|
||||
onRefreshAccount={(accountId) => void handleRefreshAccount(accountId)}
|
||||
onSelectAccount={handleSelectAccount}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onLogout={(accountId) => void handleLogout(accountId)}
|
||||
@@ -793,6 +900,8 @@ const Account = () => {
|
||||
recordTypeOptions={recordTypeOptions}
|
||||
courseRecordTypeOptions={statusOptions}
|
||||
records={records()}
|
||||
workList={workList()}
|
||||
examList={examList()}
|
||||
studyLogs={studyLogs()}
|
||||
recordsLoading={recordsLoading()}
|
||||
recordError={recordError()}
|
||||
@@ -815,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}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
Show,
|
||||
} from "solid-js";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { versionApi } from "~/service/wk";
|
||||
import {
|
||||
fetchDebugLogSnapshot,
|
||||
resolveDebugLogDownloadUrl,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
import { settingsStore } from "~/store/settings";
|
||||
|
||||
type DebugSocketState = "connecting" | "open" | "closed" | "error";
|
||||
type DebugDetailTab = "overview" | "request" | "response" | "raw";
|
||||
|
||||
const MAX_DEBUG_ENTRIES = 1000;
|
||||
|
||||
@@ -44,18 +43,145 @@ const stringifyDebugFields = (fields?: Record<string, unknown>) => {
|
||||
return JSON.stringify(fields, null, 2);
|
||||
};
|
||||
|
||||
const summarizeDebugFields = (fields?: Record<string, unknown>) => {
|
||||
const value = stringifyDebugFields(fields);
|
||||
if (!value) {
|
||||
return "-";
|
||||
const stringifyDebugValue = (value: unknown) => {
|
||||
if (
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
(typeof value === "string" && value.trim() === "")
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return value.replace(/\s+/g, " ").slice(0, 180);
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return JSON.stringify(value, null, 2);
|
||||
};
|
||||
|
||||
const getField = (value: unknown, path: string[]) => {
|
||||
let current = value;
|
||||
for (const key of path) {
|
||||
if (!current || typeof current !== "object" || !(key in current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const joinPathAndQuery = (path: string, query: string) => {
|
||||
if (!path) {
|
||||
return query ? `?${query}` : "";
|
||||
}
|
||||
return query ? `${path}?${query}` : path;
|
||||
};
|
||||
|
||||
const resolveEntrySummary = (entry: DebugLogEntry) => {
|
||||
const fields = entry.fields;
|
||||
const requestUri = stringifyDebugValue(getField(fields, ["request", "uri"]));
|
||||
if (requestUri) {
|
||||
return requestUri;
|
||||
}
|
||||
|
||||
const requestHost = stringifyDebugValue(getField(fields, ["request", "host"]));
|
||||
if (requestHost) {
|
||||
return requestHost;
|
||||
}
|
||||
|
||||
const path = stringifyDebugValue(fields?.path);
|
||||
const rawQuery = stringifyDebugValue(fields?.rawQuery);
|
||||
const url = joinPathAndQuery(path, rawQuery);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
|
||||
return entry.message || "-";
|
||||
};
|
||||
|
||||
const resolveEntryMethod = (entry: DebugLogEntry) => {
|
||||
return (
|
||||
stringifyDebugValue(getField(entry.fields, ["request", "method"])) ||
|
||||
stringifyDebugValue(entry.fields?.method) ||
|
||||
"-"
|
||||
);
|
||||
};
|
||||
|
||||
const resolveRequestMeta = (entry: DebugLogEntry) => {
|
||||
const fields = entry.fields;
|
||||
const path = stringifyDebugValue(fields?.path);
|
||||
const rawQuery = stringifyDebugValue(fields?.rawQuery);
|
||||
const url = resolveEntrySummary(entry);
|
||||
|
||||
return {
|
||||
method: resolveEntryMethod(entry),
|
||||
url,
|
||||
host: stringifyDebugValue(getField(fields, ["request", "host"])),
|
||||
path: path ? joinPathAndQuery(path, rawQuery) : "",
|
||||
proto:
|
||||
stringifyDebugValue(getField(fields, ["request", "proto"])) ||
|
||||
stringifyDebugValue(fields?.proto),
|
||||
attempt: stringifyDebugValue(getField(fields, ["request", "attempt"])),
|
||||
clientIP: stringifyDebugValue(fields?.clientIP),
|
||||
handler: stringifyDebugValue(fields?.handler),
|
||||
headers:
|
||||
stringifyDebugValue(getField(fields, ["request", "header"])) ||
|
||||
stringifyDebugValue(fields?.requestHeader),
|
||||
body:
|
||||
stringifyDebugValue(getField(fields, ["request", "body"])) ||
|
||||
stringifyDebugValue(fields?.requestBody),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveResponseMeta = (entry: DebugLogEntry) => {
|
||||
const fields = entry.fields;
|
||||
return {
|
||||
status:
|
||||
stringifyDebugValue(getField(fields, ["response", "status"])) ||
|
||||
stringifyDebugValue(fields?.status),
|
||||
statusCode:
|
||||
stringifyDebugValue(getField(fields, ["response", "statusCode"])) ||
|
||||
stringifyDebugValue(fields?.status),
|
||||
proto: stringifyDebugValue(getField(fields, ["response", "proto"])),
|
||||
durationMs:
|
||||
stringifyDebugValue(getField(fields, ["response", "durationMs"])) ||
|
||||
stringifyDebugValue(fields?.durationMs),
|
||||
size:
|
||||
stringifyDebugValue(getField(fields, ["response", "size"])) ||
|
||||
stringifyDebugValue(fields?.responseSize),
|
||||
receivedAt: stringifyDebugValue(getField(fields, ["response", "receivedAt"])),
|
||||
abortWithErrors: stringifyDebugValue(fields?.abortWithErrors),
|
||||
headers:
|
||||
stringifyDebugValue(getField(fields, ["response", "header"])) ||
|
||||
stringifyDebugValue(fields?.responseHeader),
|
||||
body:
|
||||
stringifyDebugValue(getField(fields, ["response", "body"])) ||
|
||||
stringifyDebugValue(fields?.responseBody),
|
||||
};
|
||||
};
|
||||
|
||||
const detailTabs: { id: DebugDetailTab; label: string }[] = [
|
||||
{ id: "overview", label: "概览" },
|
||||
{ id: "request", label: "请求" },
|
||||
{ id: "response", label: "响应" },
|
||||
{ id: "raw", label: "原始字段" },
|
||||
];
|
||||
|
||||
const DetailCodeBlock = (props: { title: string; value: string }) => {
|
||||
return (
|
||||
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
|
||||
<p class="mb-2 text-xs font-medium tracking-[0.2em] text-slate-400 uppercase">
|
||||
{props.title}
|
||||
</p>
|
||||
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
|
||||
{props.value || "-"}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DebugLogs = () => {
|
||||
const navigate = useNavigate();
|
||||
const [version] = createResource(versionApi);
|
||||
const [settingsState, setSettingsState] = createSignal(
|
||||
settingsStore.getState(),
|
||||
);
|
||||
@@ -64,6 +190,8 @@ const DebugLogs = () => {
|
||||
createSignal<DebugSocketState>("connecting");
|
||||
const [debugError, setDebugError] = createSignal("");
|
||||
const [selectedDebugEntryId, setSelectedDebugEntryId] = createSignal(0);
|
||||
const [selectedDetailTab, setSelectedDetailTab] =
|
||||
createSignal<DebugDetailTab>("overview");
|
||||
let debugLogContainerRef: HTMLDivElement | undefined;
|
||||
let debugSocket: WebSocket | null = null;
|
||||
let reconnectTimer: number | undefined;
|
||||
@@ -71,7 +199,7 @@ const DebugLogs = () => {
|
||||
const debugEntryKeySet = new Set<number>();
|
||||
|
||||
const isDebugMode = createMemo(() => {
|
||||
return (version()?.data.Mode ?? "").toLowerCase() === "debug";
|
||||
return settingsState().debugEnabled;
|
||||
});
|
||||
const latestDebugEntry = createMemo(() => {
|
||||
const rows = debugEntries();
|
||||
@@ -81,6 +209,14 @@ const DebugLogs = () => {
|
||||
const currentId = selectedDebugEntryId();
|
||||
return debugEntries().find((item) => item.id === currentId) ?? null;
|
||||
});
|
||||
const selectedRequestMeta = createMemo(() => {
|
||||
const entry = selectedDebugEntry();
|
||||
return entry ? resolveRequestMeta(entry) : null;
|
||||
});
|
||||
const selectedResponseMeta = createMemo(() => {
|
||||
const entry = selectedDebugEntry();
|
||||
return entry ? resolveResponseMeta(entry) : null;
|
||||
});
|
||||
const debugSourceCount = createMemo(() => {
|
||||
return new Set(debugEntries().map((item) => item.source)).size;
|
||||
});
|
||||
@@ -196,7 +332,7 @@ const DebugLogs = () => {
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
setDebugSocketState("error");
|
||||
setDebugError("调试日志流连接失败,请确认后端处于 debug 模式。");
|
||||
setDebugError("调试日志流连接失败,请确认设置页中的调试开关已开启。");
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
@@ -228,10 +364,6 @@ const DebugLogs = () => {
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
if (version.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDebugMode()) {
|
||||
disconnectDebugSocket();
|
||||
resetDebugEntries();
|
||||
@@ -264,6 +396,11 @@ const DebugLogs = () => {
|
||||
}
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
selectedDebugEntryId();
|
||||
setSelectedDetailTab("overview");
|
||||
});
|
||||
|
||||
const clearDebugLogs = () => {
|
||||
resetDebugEntries();
|
||||
};
|
||||
@@ -284,7 +421,7 @@ const DebugLogs = () => {
|
||||
后端日志
|
||||
</h1>
|
||||
<p class="mt-1 text-xs leading-5 text-zinc-600 sm:text-sm">
|
||||
独立查看后端 debug 模式下的请求、响应和应用日志。
|
||||
独立查看手动开启调试后的后端请求、响应和应用日志。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -313,7 +450,7 @@ const DebugLogs = () => {
|
||||
when={isDebugMode()}
|
||||
fallback={
|
||||
<div class="mt-3 flex flex-1 items-center justify-center rounded-[26px] border border-dashed border-zinc-300 bg-white/60 px-6 text-zinc-500">
|
||||
当前不是 debug 模式,后端日志页已隐藏。
|
||||
当前未开启调试,请先到设置页手动开启。
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -332,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" ||
|
||||
@@ -343,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"}
|
||||
>
|
||||
@@ -365,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}
|
||||
>
|
||||
清空视图
|
||||
@@ -384,7 +521,7 @@ const DebugLogs = () => {
|
||||
<span>来源</span>
|
||||
<span>等级</span>
|
||||
<span>消息</span>
|
||||
<span>摘要</span>
|
||||
<span>URL / Path</span>
|
||||
</div>
|
||||
<div
|
||||
ref={debugLogContainerRef}
|
||||
@@ -406,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)}
|
||||
>
|
||||
@@ -420,9 +557,9 @@ const DebugLogs = () => {
|
||||
</span>
|
||||
<span
|
||||
class="truncate text-slate-400"
|
||||
title={stringifyDebugFields(entry.fields)}
|
||||
title={resolveEntrySummary(entry)}
|
||||
>
|
||||
{summarizeDebugFields(entry.fields)}
|
||||
{resolveEntrySummary(entry)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -436,7 +573,7 @@ const DebugLogs = () => {
|
||||
<div class="border-b border-white/10 px-4 py-3">
|
||||
<p class="text-sm font-semibold text-white">日志详情</p>
|
||||
<p class="mt-1 text-xs text-slate-400">
|
||||
查看当前选中日志的完整字段与上下文。
|
||||
按请求和响应拆分查看当前日志的完整上下文。
|
||||
</p>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto px-4 py-3 font-mono text-sm text-slate-200">
|
||||
@@ -445,24 +582,125 @@ const DebugLogs = () => {
|
||||
fallback={<p class="text-slate-500">请选择左侧一条日志查看详情。</p>}
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
|
||||
<p>ID: {selectedDebugEntry()?.id}</p>
|
||||
<p>时间: {selectedDebugEntry()?.time}</p>
|
||||
<p>来源: {selectedDebugEntry()?.source}</p>
|
||||
<p>等级: {selectedDebugEntry()?.level}</p>
|
||||
<p>消息: {selectedDebugEntry()?.message}</p>
|
||||
<Show when={selectedDebugEntry()?.caller}>
|
||||
<p>调用位置: {selectedDebugEntry()?.caller}</p>
|
||||
</Show>
|
||||
<Show when={selectedDebugEntry()?.logger}>
|
||||
<p>Logger: {selectedDebugEntry()?.logger}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="rounded-[18px] border border-white/10 bg-zinc-950/90 p-3">
|
||||
<pre class="whitespace-pre-wrap break-all text-xs leading-6 text-emerald-200">
|
||||
{stringifyDebugFields(selectedDebugEntry()?.fields)}
|
||||
</pre>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={detailTabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
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 active:bg-white/20"
|
||||
}`}
|
||||
onClick={() => setSelectedDetailTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={selectedDetailTab() === "overview"}>
|
||||
<div class="grid gap-3">
|
||||
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
|
||||
<p>ID: {selectedDebugEntry()?.id}</p>
|
||||
<p>时间: {selectedDebugEntry()?.time}</p>
|
||||
<p>来源: {selectedDebugEntry()?.source}</p>
|
||||
<p>等级: {selectedDebugEntry()?.level}</p>
|
||||
<p>消息: {selectedDebugEntry()?.message}</p>
|
||||
<p>URL / Path: {resolveEntrySummary(selectedDebugEntry()!)}</p>
|
||||
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
|
||||
<Show when={selectedResponseMeta()?.status}>
|
||||
<p>响应状态: {selectedResponseMeta()?.status}</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.durationMs}>
|
||||
<p>耗时: {selectedResponseMeta()?.durationMs} ms</p>
|
||||
</Show>
|
||||
<Show when={selectedDebugEntry()?.caller}>
|
||||
<p>调用位置: {selectedDebugEntry()?.caller}</p>
|
||||
</Show>
|
||||
<Show when={selectedDebugEntry()?.logger}>
|
||||
<p>Logger: {selectedDebugEntry()?.logger}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedDetailTab() === "request"}>
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
|
||||
<p>Method: {selectedRequestMeta()?.method || "-"}</p>
|
||||
<p>URL: {selectedRequestMeta()?.url || "-"}</p>
|
||||
<Show when={selectedRequestMeta()?.path}>
|
||||
<p>Path: {selectedRequestMeta()?.path}</p>
|
||||
</Show>
|
||||
<Show when={selectedRequestMeta()?.host}>
|
||||
<p>Host: {selectedRequestMeta()?.host}</p>
|
||||
</Show>
|
||||
<Show when={selectedRequestMeta()?.proto}>
|
||||
<p>Protocol: {selectedRequestMeta()?.proto}</p>
|
||||
</Show>
|
||||
<Show when={selectedRequestMeta()?.attempt}>
|
||||
<p>Attempt: {selectedRequestMeta()?.attempt}</p>
|
||||
</Show>
|
||||
<Show when={selectedRequestMeta()?.clientIP}>
|
||||
<p>Client IP: {selectedRequestMeta()?.clientIP}</p>
|
||||
</Show>
|
||||
<Show when={selectedRequestMeta()?.handler}>
|
||||
<p>Handler: {selectedRequestMeta()?.handler}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<DetailCodeBlock
|
||||
title="请求头"
|
||||
value={selectedRequestMeta()?.headers || ""}
|
||||
/>
|
||||
<DetailCodeBlock
|
||||
title="请求体"
|
||||
value={selectedRequestMeta()?.body || ""}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedDetailTab() === "response"}>
|
||||
<div class="space-y-3">
|
||||
<div class="grid gap-2 rounded-[18px] border border-white/10 bg-white/5 px-4 py-3 text-xs text-slate-300">
|
||||
<p>响应状态: {selectedResponseMeta()?.status || "-"}</p>
|
||||
<Show when={selectedResponseMeta()?.statusCode}>
|
||||
<p>Status Code: {selectedResponseMeta()?.statusCode}</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.proto}>
|
||||
<p>Protocol: {selectedResponseMeta()?.proto}</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.durationMs}>
|
||||
<p>耗时: {selectedResponseMeta()?.durationMs} ms</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.size}>
|
||||
<p>响应大小: {selectedResponseMeta()?.size}</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.receivedAt}>
|
||||
<p>接收时间: {selectedResponseMeta()?.receivedAt}</p>
|
||||
</Show>
|
||||
<Show when={selectedResponseMeta()?.abortWithErrors}>
|
||||
<p>错误信息: {selectedResponseMeta()?.abortWithErrors}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<DetailCodeBlock
|
||||
title="响应头"
|
||||
value={selectedResponseMeta()?.headers || ""}
|
||||
/>
|
||||
<DetailCodeBlock
|
||||
title="响应体"
|
||||
value={selectedResponseMeta()?.body || ""}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={selectedDetailTab() === "raw"}>
|
||||
<DetailCodeBlock
|
||||
title="原始字段"
|
||||
value={stringifyDebugFields(selectedDebugEntry()?.fields)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
清空全部日志
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -8,6 +9,15 @@ const Setting = () => {
|
||||
const [hostValue, setHostValue] = createSignal("");
|
||||
const [isLoadingRemoteHosts, setIsLoadingRemoteHosts] = createSignal(false);
|
||||
const [hostError, setHostError] = createSignal("");
|
||||
const [debugSyncing, setDebugSyncing] = createSignal(false);
|
||||
const [debugError, setDebugError] = createSignal("");
|
||||
const [backendDebugState, setBackendDebugState] = createSignal<{
|
||||
enabled: boolean;
|
||||
proxy: string;
|
||||
skipSSLVerify: boolean;
|
||||
buildMode: string;
|
||||
proxyConfigured: boolean;
|
||||
} | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = settingsStore.subscribe((nextState) => {
|
||||
@@ -44,6 +54,49 @@ const Setting = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshDebugConfig = async () => {
|
||||
try {
|
||||
const res = await fetchDebugConfig();
|
||||
setBackendDebugState({
|
||||
enabled: res.data.enabled,
|
||||
proxy: res.data.proxy,
|
||||
skipSSLVerify: res.data.skip_ssl_verify,
|
||||
buildMode: res.data.build_mode,
|
||||
proxyConfigured: res.data.proxy_configured,
|
||||
});
|
||||
setDebugError("");
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "获取调试配置失败。";
|
||||
setDebugError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDebugToggle = async (enabled: boolean) => {
|
||||
const previous = state().debugEnabled;
|
||||
settingsStore.getState().setDebugEnabled(enabled);
|
||||
setDebugSyncing(true);
|
||||
setDebugError("");
|
||||
|
||||
try {
|
||||
const res = await updateDebugConfig(enabled);
|
||||
setBackendDebugState({
|
||||
enabled: res.data.enabled,
|
||||
proxy: res.data.proxy,
|
||||
skipSSLVerify: res.data.skip_ssl_verify,
|
||||
buildMode: res.data.build_mode,
|
||||
proxyConfigured: res.data.proxy_configured,
|
||||
});
|
||||
} catch (error) {
|
||||
settingsStore.getState().setDebugEnabled(previous);
|
||||
const message =
|
||||
error instanceof Error ? error.message : "更新调试开关失败。";
|
||||
setDebugError(message);
|
||||
} finally {
|
||||
setDebugSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLocalHost = () => {
|
||||
if (!hostLabel().trim() || !hostValue().trim()) {
|
||||
return;
|
||||
@@ -61,376 +114,370 @@ const Setting = () => {
|
||||
if (state().remoteHosts.length === 0) {
|
||||
void loadRemoteHosts();
|
||||
}
|
||||
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="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">
|
||||
新日志出现时自动滚动到最底部
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={state().autoScrollLogs}
|
||||
onChange={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setAutoScrollLogs(event.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</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="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">
|
||||
后续日志输出可以追加格式化时间
|
||||
</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",
|
||||
)
|
||||
}
|
||||
<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()}
|
||||
>
|
||||
<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>
|
||||
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
<input
|
||||
class="mt-4 w-full"
|
||||
type="range"
|
||||
min="11"
|
||||
max="16"
|
||||
value={state().logFontSize}
|
||||
onInput={(event) =>
|
||||
settingsStore
|
||||
.getState()
|
||||
.setLogFontSize(Number(event.currentTarget.value))
|
||||
}
|
||||
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="名称,如:校内测试"
|
||||
/>
|
||||
</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))
|
||||
}
|
||||
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="Host,如:example.com"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
<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()}
|
||||
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}
|
||||
>
|
||||
{isLoadingRemoteHosts() ? "获取中..." : "获取远程 Host"}
|
||||
添加本地 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="Host,如:example.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>
|
||||
{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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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="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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import http from "~/service/http";
|
||||
|
||||
export type DebugLogEntry = {
|
||||
id: number;
|
||||
time: string;
|
||||
@@ -17,6 +19,18 @@ type DebugLogListResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
type DebugConfigResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
data: {
|
||||
enabled: boolean;
|
||||
proxy: string;
|
||||
skip_ssl_verify: boolean;
|
||||
build_mode: string;
|
||||
proxy_configured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
const toWsProtocol = (protocol: string) => {
|
||||
return protocol === "https:" ? "wss:" : "ws:";
|
||||
};
|
||||
@@ -61,3 +75,13 @@ export const fetchDebugLogSnapshot = async () => {
|
||||
const payload = (await response.json()) as DebugLogListResponse;
|
||||
return payload.data.list ?? [];
|
||||
};
|
||||
|
||||
export const fetchDebugConfig = async () => {
|
||||
return await http.get<DebugConfigResponse>("/api/debug/config");
|
||||
};
|
||||
|
||||
export const updateDebugConfig = async (enabled: boolean) => {
|
||||
return await http.post<DebugConfigResponse>("/api/debug/config", {
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ type UnauthorizedHandler = (sessionId: string) => Promise<boolean>;
|
||||
|
||||
type SessionResolver = () => string | undefined;
|
||||
|
||||
const DEFAULT_HTTP_TIMEOUT_MS = 15000;
|
||||
|
||||
export type HttpClient = {
|
||||
get<T>(url: string, config?: AxiosRequestConfig): Promise<T>;
|
||||
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T>;
|
||||
@@ -46,7 +48,7 @@ export const createHttpClient = (
|
||||
): HttpClient => {
|
||||
const instance = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_URL,
|
||||
timeout: 15000,
|
||||
timeout: DEFAULT_HTTP_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
instance.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
@@ -106,3 +108,4 @@ export const createHttpClient = (
|
||||
const http = createHttpClient();
|
||||
|
||||
export default http;
|
||||
export { DEFAULT_HTTP_TIMEOUT_MS };
|
||||
|
||||
57
src/service/silentAudio.ts
Normal file
57
src/service/silentAudio.ts
Normal 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;
|
||||
};
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Accessor } from "solid-js";
|
||||
import http, { createHttpClient, type HttpClient } from "~/service/http";
|
||||
import http, {
|
||||
createHttpClient,
|
||||
type HttpClient,
|
||||
} from "~/service/http";
|
||||
import type { CourseType } from "~/types/Course";
|
||||
import type { userInfoType } from "~/types/Userinfo";
|
||||
|
||||
@@ -9,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;
|
||||
@@ -24,9 +101,7 @@ export type LoginReq = {
|
||||
};
|
||||
|
||||
export type LoginData = {
|
||||
courses?: CourseType[];
|
||||
session_id: string;
|
||||
user: userInfoType;
|
||||
};
|
||||
|
||||
export type LoginRes = ApiResponse<LoginData>;
|
||||
@@ -130,6 +205,12 @@ export type CourseData = {
|
||||
|
||||
export type CourseRes = ApiResponse<CourseData>;
|
||||
|
||||
export type UserInfoData = {
|
||||
user: userInfoType;
|
||||
};
|
||||
|
||||
export type UserInfoRes = ApiResponse<UserInfoData>;
|
||||
|
||||
export type StudyReq = {
|
||||
node_id: string;
|
||||
study_id: string;
|
||||
@@ -157,24 +238,68 @@ 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;
|
||||
// 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);
|
||||
return res;
|
||||
};
|
||||
|
||||
const createWkClientFromHttp = (client: HttpClient): WkClient => ({
|
||||
userInfoApi() {
|
||||
return client.post<UserInfoRes>("/api/v2/userinfo");
|
||||
},
|
||||
courseApi(payload) {
|
||||
return client.post<CourseRes>("/api/v2/course", payload);
|
||||
return client.post<CourseRes>("/api/v2/course", payload, {
|
||||
timeout: COURSE_API_TIMEOUT_MS,
|
||||
});
|
||||
},
|
||||
recordApi(payload) {
|
||||
return client.post<RecordRes>("/api/v2/record", payload);
|
||||
return client.post<RecordRes>("/api/v2/record", payload, {
|
||||
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);
|
||||
@@ -190,6 +315,10 @@ export const createWkClient = (
|
||||
return createWkClientFromHttp(createHttpClient(resolveSessionId));
|
||||
};
|
||||
|
||||
export const createSessionWkClient = (sessionId: string): WkClient => {
|
||||
return createWkClient(() => sessionId);
|
||||
};
|
||||
|
||||
const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
||||
|
||||
export const runStudyQueue = async (_payload: StudyRunnerPayload) => {
|
||||
@@ -290,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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -14,6 +15,7 @@ type SettingsState = {
|
||||
persistAccounts: boolean;
|
||||
persistRecords: boolean;
|
||||
persistLogs: boolean;
|
||||
debugEnabled: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
showLogTimestamps: boolean;
|
||||
densityMode: DensityMode;
|
||||
@@ -24,6 +26,7 @@ type SettingsState = {
|
||||
setPersistSection: (section: CacheSection, value: boolean) => void;
|
||||
clearPersistedSection: (section: CacheSection) => void;
|
||||
clearAllPersistedData: () => void;
|
||||
setDebugEnabled: (value: boolean) => void;
|
||||
setAutoScrollLogs: (value: boolean) => void;
|
||||
setShowLogTimestamps: (value: boolean) => void;
|
||||
setDensityMode: (value: DensityMode) => void;
|
||||
@@ -35,6 +38,7 @@ type SettingsState = {
|
||||
};
|
||||
|
||||
const accountStorageKey = "account-storage";
|
||||
const settingsStorageKey = "settings-storage";
|
||||
type PersistedStorage = {
|
||||
state?: Record<string, unknown>;
|
||||
version?: number;
|
||||
@@ -93,6 +97,7 @@ export const settingsStore = createStore<SettingsState>()(
|
||||
persistAccounts: true,
|
||||
persistRecords: true,
|
||||
persistLogs: true,
|
||||
debugEnabled: false,
|
||||
autoScrollLogs: true,
|
||||
showLogTimestamps: false,
|
||||
densityMode: "comfortable",
|
||||
@@ -112,54 +117,42 @@ 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 }),
|
||||
setShowLogTimestamps: (value) => set({ showLogTimestamps: value }),
|
||||
setDensityMode: (value) => set({ densityMode: value }),
|
||||
|
||||
Reference in New Issue
Block a user