diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index cfac5cd..707dd5d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,6 +1,5 @@ name: CI -# 仅分支推送触发:tag 推送交给 release.yml;solo 开发不走 PR,去掉 pull_request 避免同一提交双跑 on: push: branches: ["**"] @@ -20,5 +19,7 @@ jobs: run: npx eslint . - name: 类型检查 run: npm run typecheck + - name: 单元测试 + run: npm run test - name: 构建 run: npm run build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d26a91..5195e1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,5 +19,7 @@ jobs: run: npx eslint . - name: 类型检查 run: npm run typecheck + - name: 单元测试 + run: npm run test - name: 构建 run: npm run build diff --git a/.trellis/.version b/.trellis/.version index bf21f52..8b707c6 100644 --- a/.trellis/.version +++ b/.trellis/.version @@ -1 +1 @@ -0.6.6 \ No newline at end of file +0.6.7 \ No newline at end of file diff --git a/.trellis/spec/frontend/api-and-state.md b/.trellis/spec/frontend/api-and-state.md index a77eb1c..c9bc131 100644 --- a/.trellis/spec/frontend/api-and-state.md +++ b/.trellis/spec/frontend/api-and-state.md @@ -9,3 +9,13 @@ - 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。 - Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。 - 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。 +- 表单先上传临时远端资源、再创建业务对象时:上传响应保存 `{cfgId, domainId, url, fileName}` + 原始元组,用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除, + 失败静默(孤儿只是自己租户里一张无入口小图)。创建请求引用了它且返回 2xx + (含 `201 + setupWarning`)即视为已采用不再清理;清理复杂度须与资源价值匹配, + 勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。 + 域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。 +- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17) +- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15) +- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16) +- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载) diff --git a/.trellis/spec/frontend/index.md b/.trellis/spec/frontend/index.md index 8b09e61..4d8e687 100644 --- a/.trellis/spec/frontend/index.md +++ b/.trellis/spec/frontend/index.md @@ -6,7 +6,7 @@ ## 技术栈约束 -Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。 +Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。选型对比与理由归档于 `.trellis/tasks/archive/2026-07/07-06-frontend-design-research/`。 --- @@ -28,7 +28,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive - [ ] 读过 [Directory Structure](./directory-structure.md),文件放对层(views / components / composables / api / stores / types) - [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex - [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应) -- [ ] 出 UI 设计稿前读 `../guides/design-workflow.md` +- [ ] 出 UI 设计稿前读 `../../guides/design-workflow.md` ## Quality Check @@ -37,6 +37,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive ```bash npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier npm run typecheck # vue-tsc --noEmit +npm run test # vitest(happy-dom),*.test.ts 与被测模块同目录 npm run build # 类型检查 + 产物构建 ``` diff --git a/.trellis/spec/guides/code-reuse-thinking-guide.md b/.trellis/spec/guides/code-reuse-thinking-guide.md deleted file mode 100644 index bb789e9..0000000 --- a/.trellis/spec/guides/code-reuse-thinking-guide.md +++ /dev/null @@ -1,223 +0,0 @@ -# Code Reuse Thinking Guide - -> **Purpose**: Stop and think before creating new code - does it already exist? - ---- - -## The Problem - -**Duplicated code is the #1 source of inconsistency bugs.** - -When you copy-paste or rewrite existing logic: -- Bug fixes don't propagate -- Behavior diverges over time -- Codebase becomes harder to understand - ---- - -## Before Writing New Code - -### Step 1: Search First - -```bash -# Search for similar function names -grep -r "functionName" . - -# Search for similar logic -grep -r "keyword" . -``` - -### Step 2: Ask These Questions - -| Question | If Yes... | -|----------|-----------| -| Does a similar function exist? | Use or extend it | -| Is this pattern used elsewhere? | Follow the existing pattern | -| Could this be a shared utility? | Create it in the right place | -| Am I copying code from another file? | **STOP** - extract to shared | - ---- - -## Common Duplication Patterns - -### Pattern 1: Copy-Paste Functions - -**Bad**: Copying a validation function to another file - -**Good**: Extract to shared utilities, import where needed - -### Pattern 2: Similar Components - -**Bad**: Creating a new component that's 80% similar to existing - -**Good**: Extend existing component with props/variants - -### Pattern 3: Repeated Constants - -**Bad**: Defining the same constant in multiple files - -**Good**: Single source of truth, import everywhere - -### Pattern 4: Repeated Payload Field Extraction - -**Bad**: Multiple consumers cast the same JSON/event fields locally: - -```typescript -const description = (ev as { description?: string }).description; -const context = (ev as { context?: ContextEntry[] }).context; -``` - -This is duplicated contract logic even when the code is only two lines. Each -consumer now has its own definition of what a valid payload means. - -**Good**: Put the decoder, type guard, or projection next to the data owner: - -```typescript -if (isThreadEvent(ev)) { - renderThreadEvent(ev); -} -``` - -**Rule**: If the same untyped payload field is read in 2+ places, create a -shared type guard / normalizer / projection before adding a third reader. - ---- - -## When to Abstract - -**Abstract when**: -- Same code appears 3+ times -- Logic is complex enough to have bugs -- Multiple people might need this - -**Don't abstract when**: -- Only used once -- Trivial one-liner -- Abstraction would be more complex than duplication - ---- - -## After Batch Modifications - -When you've made similar changes to multiple files: - -1. **Review**: Did you catch all instances? -2. **Search**: Run grep to find any missed -3. **Consider**: Should this be abstracted? - -### Reducers Should Use Exhaustive Structure - -When state is derived from action-like values (`action`, `kind`, `status`, -`phase`), prefer a reducer with one `switch` over scattered `if/else` updates. - -```typescript -// BAD - action-specific state transitions are hard to audit -if (action === "opened") { ... } -else if (action === "comment") { ... } -else if (action === "status") { ... } - -// GOOD - one reducer owns the transition table -switch (event.action) { - case "opened": - ... - return; - case "comment": - ... - return; -} -``` - -This matters when the event log is the source of truth. A reducer is the -documented replay model; display code and commands should not duplicate pieces -of that replay model. - ---- - -## Checklist Before Commit - -- [ ] Searched for existing similar code -- [ ] No copy-pasted logic that should be shared -- [ ] No repeated untyped payload field extraction outside a shared decoder -- [ ] Constants defined in one place -- [ ] Similar patterns follow same structure -- [ ] Reducer/action transitions live in one reducer or command dispatcher - ---- - -## Gotcha: Python if/elif/else Exhaustive Check - -**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults. - -**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised. - -**Example** (`cli_adapter.py`): -```python -# BAD: "gemini" falls through to else, returns "claude" -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - else: - return "claude" # gemini silently gets "claude"! - -# GOOD: explicit branch for every platform -@property -def cli_name(self) -> str: - if self.platform == "opencode": - return "opencode" - elif self.platform == "gemini": - return "gemini" - else: - return "claude" -``` - -**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values. - ---- - -## Gotcha: Asymmetric Mechanisms Producing Same Output - -**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts. - -**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely. - -**Prevention**: -- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list) -- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms -- When migrating directory structures, search for ALL code paths that reference the old structure - -**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3. - ---- - -## Template File Registration (Trellis-specific) - -When adding new files to `src/templates/trellis/scripts/`: - -**Single registration point**: `src/templates/trellis/index.ts` - -1. Add `export const xxxScript = readTemplate("scripts/path/file.py");` -2. Add to `getAllScripts()` Map - -That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed. - -**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate. - -**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth. - -### Quick Checklist for New Scripts - -```bash -# After adding a new .py file, verify it's in getAllScripts(): -grep -l "newFileName" src/templates/trellis/index.ts # Should match -``` - -### Template Sync Convention - -`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync: - -```bash -rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/ -``` - -**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running. diff --git a/.trellis/spec/guides/cross-layer-thinking-guide.md b/.trellis/spec/guides/cross-layer-thinking-guide.md deleted file mode 100644 index 9686546..0000000 --- a/.trellis/spec/guides/cross-layer-thinking-guide.md +++ /dev/null @@ -1,327 +0,0 @@ -# Cross-Layer Thinking Guide - -> **Purpose**: Think through data flow across layers before implementing. - ---- - -## The Problem - -**Most bugs happen at layer boundaries**, not within layers. - -Common cross-layer bugs: - -- API returns format A, frontend expects format B -- Database stores X, service transforms to Y, but loses data -- Multiple layers implement the same logic differently - ---- - -## Before Implementing Cross-Layer Features - -### Step 1: Map the Data Flow - -Draw out how data moves: - -``` -Source → Transform → Store → Retrieve → Transform → Display -``` - -For each arrow, ask: - -- What format is the data in? -- What could go wrong? -- Who is responsible for validation? - -### Step 2: Identify Boundaries - -| Boundary | Common Issues | -| --------------------- | --------------------------------- | -| API ↔ Service | Type mismatches, missing fields | -| Service ↔ Database | Format conversions, null handling | -| Backend ↔ Frontend | Serialization, date formats | -| Component ↔ Component | Props shape changes | - -### Step 3: Define Contracts - -For each boundary: - -- What is the exact input format? -- What is the exact output format? -- What errors can occur? - ---- - -## Common Cross-Layer Mistakes - -### Mistake 1: Implicit Format Assumptions - -**Bad**: Assuming date format without checking - -**Good**: Explicit format conversion at boundaries - -### Mistake 2: Scattered Validation - -**Bad**: Validating the same thing in multiple layers - -**Good**: Validate once at the entry point - -### Mistake 3: Leaky Abstractions - -**Bad**: Component knows about database schema - -**Good**: Each layer only knows its neighbors - -### Mistake 4: Every Consumer Parses The Same Payload - -**Bad**: A command reads JSONL events and casts fields inline: - -```typescript -const thread = (ev as { thread?: string }).thread; -const labels = (ev as { labels?: string[] }).labels; -``` - -This looks local, but it means every consumer owns a private version of the -event contract. The next field change will update one command and miss another. - -**Good**: Decode once at the event boundary, then export typed projections: - -```typescript -if (!isThreadEvent(ev)) return false; -return ev.thread === filter.thread; -``` - -**Rule**: For append-only logs, JSON streams, RPC payloads, or config files, -create one owner for: - -- event / payload type definitions -- type guards and normalization from `unknown` -- metadata projections used by UI commands -- reducers that replay state from the source of truth - -Rendering code may format fields, but it must not redefine the payload contract. - ---- - -## Checklist for Cross-Layer Features - -Before implementation: - -- [ ] Mapped the complete data flow -- [ ] Identified all layer boundaries -- [ ] Defined format at each boundary -- [ ] Decided where validation happens - -After implementation: - -- [ ] Tested with edge cases (null, empty, invalid) -- [ ] Verified error handling at each boundary -- [ ] Checked data survives round-trip -- [ ] Checked that consumers import shared decoders / projections instead of - casting payload fields locally -- [ ] Checked that derived state points back to the source event identifier - (`seq`, `id`, `version`) instead of inventing a second cursor - ---- - -## Cross-Platform Template Consistency - -In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. - -### Checklist: After Modifying Any Command Template - -- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name ".*"` -- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) -- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings -- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed - -**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. - ---- - -## Generated Runtime Template Upgrade Consistency - -Some generated files are both documentation and runtime input. In Trellis, -`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, -SessionStart filters, and per-turn hooks. Template changes must be validated -against both fresh init and upgrade paths. - -### Checklist: After Modifying A Runtime-Parsed Template - -- [ ] Identify every runtime parser that reads the template, not just the file - writer that installs it -- [ ] Check whether relevant syntax lives outside obvious managed regions - such as tag blocks -- [ ] Verify fresh `init` output and a versioned `update` scenario that writes - the older `.trellis/.version` -- [ ] Add an upgrade regression using an older pristine template fixture, then - assert the installed file reaches the current packaged shape -- [ ] Update the backend spec that owns the runtime contract - ---- - -## Versioned Documentation Boundary - -Versioned documentation is a cross-layer boundary: source paths, `docs.json` -version routing, and the rendered version selector must all describe the same -release line. - -### Checklist: Before Editing Versioned Docs - -- [ ] Identify the target release line: stable, beta, or RC -- [ ] Verify the edited MDX path matches that line: - - stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}` - - beta: `docs-site/beta/**` and `docs-site/zh/beta/**` - - RC: `docs-site/rc/**` and `docs-site/zh/rc/**` -- [ ] Verify `docs.json` navigation points the version label to the same paths -- [ ] Grep the opposite tree for release-line-specific terms before committing -- [ ] Treat beta content appearing under root release paths as a source-path bug, - not a rendering bug - -**Real-world example**: A beta-only task workflow change documented -`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex -mode banners under root `start/` and `advanced/` paths. The docs site then -served 0.6 beta behavior under the Release selector. The fix was to restore root -release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep -audit for beta markers against the root release tree. - -**Real-world example**: Codex inline mode changed workflow platform markers from -`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / -`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but -`trellis update` only merged `[workflow-state:*]` blocks and preserved stale -markers outside those blocks. Result: upgraded projects got new hook scripts -but old workflow routing, so `get_context.py --mode phase --platform codex` -could return empty Phase 2.1 detail. - ---- - -## Mode-Detection Probe Checklist - -When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): - -### Before implementing: - -- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) -- [ ] 404 vs transient error are distinguished — don't treat both as "not found" -- [ ] Transient errors **abort or retry**, never silently switch modes -- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) -- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers - -### After implementing: - -- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough -- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments -- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON -- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) -- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters - -**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". - -**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. - ---- - -## Cross-Platform Template Consistency - -In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary. - -### Checklist: After Modifying Any Command Template - -- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name ".*"` -- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`) -- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings -- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed - -**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check. - ---- - -## Generated Runtime Template Upgrade Consistency - -Some generated files are both documentation and runtime input. In Trellis, -`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`, -SessionStart filters, and per-turn hooks. Template changes must be validated -against both fresh init and upgrade paths. - -### Checklist: After Modifying A Runtime-Parsed Template - -- [ ] Identify every runtime parser that reads the template, not just the file - writer that installs it -- [ ] Check whether relevant syntax lives outside obvious managed regions - such as tag blocks -- [ ] Verify fresh `init` output and a versioned `update` scenario that writes - the older `.trellis/.version` -- [ ] Add an upgrade regression using an older pristine template fixture, then - assert the installed file reaches the current packaged shape -- [ ] Update the backend spec that owns the runtime contract - -**Real-world example**: Codex inline mode changed workflow platform markers from -`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` / -`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but -`trellis update` only merged `[workflow-state:*]` blocks and preserved stale -markers outside those blocks. Result: upgraded projects got new hook scripts -but old workflow routing, so `get_context.py --mode phase --platform codex` -could return empty Phase 2.1 detail. - ---- - -## Mode-Detection Probe Checklist - -When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download): - -### Before implementing: -- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos) -- [ ] 404 vs transient error are distinguished — don't treat both as "not found" -- [ ] Transient errors **abort or retry**, never silently switch modes -- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source) -- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers - -### After implementing: -- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough -- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments -- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON -- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`) -- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters - -**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found". - -**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail. - ---- - -## When to Create Flow Documentation - -Create detailed flow docs when: - -- Feature spans 3+ layers -- Multiple teams are involved -- Data format is complex -- Feature has caused bugs before - ---- - -## Event Log / Projection Boundary - -Append-only logs are cross-layer contracts. A single event travels through: - -``` -CLI input → event writer → events.jsonl → reader → filter → reducer → display -``` - -### Checklist: After Adding A New Event Kind Or Field - -- [ ] Add the event kind to the central event taxonomy -- [ ] Add a typed event variant or type guard at the event layer -- [ ] Add normalization helpers for array/object fields that come from - user input or JSON -- [ ] Keep `seq` / `id` assignment in the event writer only -- [ ] Make filters and reducers consume the typed event guard, not local casts -- [ ] Make display code consume reducer output or typed events, not raw JSON -- [ ] Add at least one regression that proves history replay and live filtering - use the same filter model - -**Real-world example**: Thread channels added `kind: "thread"`, `description`, -`context`, labels, and `lastSeq`. The first implementation replayed thread -state correctly, but several commands still re-parsed event payload fields with -local casts. The fix was to make the core event layer own `ThreadChannelEvent` -and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata -projection, and make `reduceThreads` the only thread replay reducer. diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md index 28c5dca..8eaddb9 100644 --- a/.trellis/spec/guides/index.md +++ b/.trellis/spec/guides/index.md @@ -1,98 +1,24 @@ -# Thinking Guides +# 项目 Guides -> **Purpose**: Expand your thinking to catch things you might not have considered. - ---- - -## Why Thinking Guides? - -**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill: - -- Didn't think about what happens at layer boundaries → cross-layer bugs -- Didn't think about code patterns repeating → duplicated code everywhere -- Didn't think about edge cases → runtime errors -- Didn't think about future maintainers → unreadable code - -These guides help you **ask the right questions before coding**. - ---- +> 跨仓库的工作纪律与流程约定;各层编码规范见 `oci-portal/backend/` 与 `oci-portal-dash/frontend/`。 ## Available Guides -| Guide | Purpose | When to Use | -|-------|---------|-------------| -| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns | -| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers | +| Guide | 内容 | 何时读 | +|-------|------|--------| | [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 | ---- +## AI 交叉评审结果核验 -## Quick Reference: Thinking Triggers +- 评审称「用户输入可能是恶意的」→ 先查实际数据源:内部清单?用户配置?外部 API? +- 评审标「缺少校验」→ 数据是否来自可信内部源? +- 评审称「行为变化」→ 读代码注释——是否本就是有意设计? +- 评审指测试有 bug → 心里删掉被测功能,测试还过吗?过 = 同义反复测试 -### When to Think About Cross-Layer Issues +常见误报模式:信任边界混淆(把内置数据当外部输入)、无视设计注释、变量误读(未追到实际定义)。 -- [ ] Feature touches 3+ layers (API, Service, Component, Database) -- [ ] Data format changes between layers -- [ ] Multiple consumers need the same data -- [ ] You're not sure where to put some logic -- [ ] You are adding an event kind, JSONL record, RPC payload, or config field -- [ ] UI / command code starts casting raw payload fields directly - -→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) - -### When to Think About Code Reuse - -- [ ] You're writing similar code to something that exists -- [ ] You see the same pattern repeated 3+ times -- [ ] You're adding a new field to multiple places -- [ ] **You're modifying any constant or config** -- [ ] **You're creating a new utility/helper function** ← Search first! -- [ ] Two files read the same untyped payload field with local casts -- [ ] Multiple branches update the same derived state from `kind` / `action` - -→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) - -### When Verifying AI Cross-Review Results - -- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?) -- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source? -- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design? -- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test - -**Common AI reviewer false-positive patterns**: -1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input -2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs -3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name) - -**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews. +**核验规则**:每条 CRITICAL/WARNING 结论必须对照实际代码核验后再排优先级;AI 评审按 ~35% 误报率做预算。 --- -## Pre-Modification Rule (CRITICAL) - -> **Before changing ANY value, ALWAYS search first!** - -```bash -# Search for the value you're about to change -grep -r "value_to_change" . -``` - -This single habit prevents most "forgot to update X" bugs. - ---- - -## How to Use This Directory - -1. **Before coding**: Skim the relevant thinking guide -2. **During coding**: If something feels repetitive or complex, check the guides -3. **After bugs**: Add new insights to the relevant guide (learn from mistakes) - ---- - -## Contributing - -Found a new "didn't think of that" moment? Add it to the relevant guide. - ---- - -**Core Principle**: 30 minutes of thinking saves 3 hours of debugging. +2026-07 已删除上游模板自带的两个通用 thinking guide(code-reuse / cross-layer):内容为通用常识与 Trellis 自身开发案例,与本项目无关。项目教训一律直接沉淀到对应层的规范文件,不建通用思维文档。 diff --git a/CLAUDE.md b/CLAUDE.md index 6c0f2dd..6c6442e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,7 @@ oci-portal-dash:OCI Portal 前端(Vue 3 + Vite + TypeScript + Naive UI + Tai ## 质量门(提交前必过) ```bash -npm run lint && npm run typecheck && npm run build +npm run lint && npm run typecheck && npm run test && npm run build ``` ## 文档同步 diff --git a/README.md b/README.md index 8294843..a6931dc 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ npm run dev # http://localhost:5173,默认 mock 模式开箱可视 ```bash npm run lint # ESLint + Prettier(带 --fix) npm run typecheck # vue-tsc --noEmit +npm run test # vitest 单元测试(happy-dom 环境) npm run build # 类型检查 + 产物构建到 dist/ ``` diff --git a/package-lock.json b/package-lock.json index 19fcabf..bd76414 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "oci-portal-dash", - "version": "0.2.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oci-portal-dash", - "version": "0.2.0", + "version": "0.8.0", "dependencies": { "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-go": "^6.0.1", @@ -50,11 +50,13 @@ "@vue/tsconfig": "^0.7.0", "eslint": "^9.18.0", "eslint-plugin-vue": "^9.32.0", + "happy-dom": "^20.11.0", "prettier": "^3.9.5", "tailwindcss": "^4.0.0", "typescript": "~5.7.3", "vite": "^6.0.7", "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.10", "vue-tsc": "^2.2.0" } }, @@ -3412,6 +3414,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", @@ -3719,6 +3728,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3772,6 +3799,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -4029,6 +4073,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -4497,6 +4664,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4695,6 +4872,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -4776,6 +4966,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5300,6 +5500,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -5797,6 +6004,16 @@ "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6266,6 +6483,25 @@ "dev": true, "license": "ISC" }, + "node_modules/happy-dom": { + "version": "20.11.0", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz", + "integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7631,6 +7867,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -7780,6 +8030,13 @@ "node": "20 || >=22" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8417,6 +8674,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8480,6 +8744,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8750,6 +9028,23 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -8798,6 +9093,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9279,6 +9584,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vooks": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", @@ -9448,6 +9856,16 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9553,6 +9971,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -9824,6 +10259,28 @@ "workbox-core": "7.4.1" } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 3065d10..221c30a 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "typecheck": "vue-tsc --noEmit", "lint": "eslint . --fix", + "test": "vitest run", "format": "prettier --write src/" }, "dependencies": { @@ -54,11 +55,13 @@ "@vue/tsconfig": "^0.7.0", "eslint": "^9.18.0", "eslint-plugin-vue": "^9.32.0", + "happy-dom": "^20.11.0", "prettier": "^3.9.5", "tailwindcss": "^4.0.0", "typescript": "~5.7.3", "vite": "^6.0.7", "vite-plugin-pwa": "^1.3.0", + "vitest": "^4.1.10", "vue-tsc": "^2.2.0" } } diff --git a/src/api/auth.ts b/src/api/auth.ts index 4bdae8e..efc5397 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -129,34 +129,31 @@ export function unbindIdentity(id: number): Promise { // ---- OAuth provider 配置(设置页) ---- +const mockOauth: OAuthSettings = { + oidcIssuer: '', + oidcClientId: '', + oidcSecretSet: false, + oidcDisplayName: '', + oidcDisabled: false, + githubClientId: '', + githubSecretSet: false, + githubDisplayName: '', + githubDisabled: false, +} + export function getOAuthSettings(): Promise { - if (mockOn) - return mocked({ - oidcIssuer: '', - oidcClientId: '', - oidcSecretSet: false, - oidcDisplayName: '', - oidcDisabled: false, - githubClientId: '', - githubSecretSet: false, - githubDisplayName: '', - githubDisabled: false, - }) + if (mockOn) return mocked({ ...mockOauth }) return request('/settings/oauth') } +/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */ export function updateOAuthSettings(body: UpdateOAuthRequest): Promise { - if (mockOn) - return mocked({ - oidcIssuer: body.oidcIssuer, - oidcClientId: body.oidcClientId, - oidcSecretSet: !!body.oidcClientSecret, - oidcDisplayName: body.oidcDisplayName, - oidcDisabled: body.oidcDisabled, - githubClientId: body.githubClientId, - githubSecretSet: !!body.githubClientSecret, - githubDisplayName: body.githubDisplayName, - githubDisabled: body.githubDisabled, - }) - return request('/settings/oauth', { method: 'PUT', body }) + if (mockOn) { + const { oidcClientSecret, githubClientSecret, ...rest } = body + Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined))) + if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== '' + if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== '' + return mocked({ ...mockOauth }) + } + return request('/settings/oauth', { method: 'PATCH', body }) } diff --git a/src/api/request.test.ts b/src/api/request.test.ts new file mode 100644 index 0000000..1c2aa78 --- /dev/null +++ b/src/api/request.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { request, ApiError } from './request' +import { useAuthStore } from '@/stores/auth' + +/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */ +function setPageURL(url: string) { + ;(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url) +} + +/** 构造最小 401 响应;rotate 在响应返回前换发新会话,模拟 OAuth 绑定回跳 */ +function stub401(rotateTo?: string) { + vi.stubGlobal('fetch', async () => { + if (rotateTo !== undefined) useAuthStore().setSession(rotateTo, '') + return { + ok: false, + status: 401, + url: '/api/v1/x', + json: async () => ({ error: '未认证' }), + } as unknown as Response + }) +} + +let assignSpy: ReturnType + +beforeEach(() => { + localStorage.clear() + setActivePinia(createPinia()) + assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('401 令牌快照', () => { + it('当前 token 收到 401:登出并带 redirect 跳登录', async () => { + setPageURL('http://localhost/instances?tab=list') + useAuthStore().setSession('tokA', '') + stub401() + await expect(request('/x')).rejects.toMatchObject({ status: 401, message: '未认证' }) + expect(useAuthStore().token).toBe('') + expect(assignSpy).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent('/instances?tab=list')}`, + ) + }) + + it('响应到达前会话已换新:旧 token 的迟到 401 不得清掉新会话', async () => { + setPageURL('http://localhost/settings') + useAuthStore().setSession('tokA', '') + stub401('tokB') + await expect(request('/x')).rejects.toBeInstanceOf(ApiError) + expect(useAuthStore().token).toBe('tokB') + expect(assignSpy).not.toHaveBeenCalled() + }) + + it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => { + setPageURL('http://localhost/login') + stub401() + await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({ + message: '未认证', + }) + expect(assignSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/api/request.ts b/src/api/request.ts index b78b1a1..1440135 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -32,7 +32,7 @@ function buildUrl(path: string, query?: RequestOptions['query']): string { return s ? `${BASE}${path}?${s}` : BASE + path } -async function parseError(resp: Response): Promise { +async function parseError(resp: Response, sentToken: string): Promise { let message = `请求失败(${resp.status})` let ociCode: string | undefined try { @@ -54,7 +54,7 @@ async function parseError(resp: Response): Promise { } catch { /* 非 JSON 响应体,保留默认消息 */ } - if (resp.status === 401) useAuthStore().logout() + if (resp.status === 401) handleUnauthorized(sentToken) // 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示 if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') { location.assign('/blocked') @@ -62,17 +62,31 @@ async function parseError(resp: Response): Promise { throw new ApiError(resp.status, message, ociCode) } +/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新 + * token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话; + * 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */ +function handleUnauthorized(sentToken: string) { + const auth = useAuthStore() + if (sentToken !== auth.token) return + auth.logout() + if (location.pathname !== '/login') { + const redirect = encodeURIComponent(location.pathname + location.search) + location.assign(`/login?redirect=${redirect}`) + } +} + /** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */ export async function request(path: string, opts: RequestOptions = {}): Promise { const auth = useAuthStore() const headers: Record = { 'Content-Type': 'application/json', ...opts.headers } if (auth.token) headers.Authorization = `Bearer ${auth.token}` + const sentToken = auth.token const resp = await fetch(buildUrl(path, opts.query), { method: opts.method ?? 'GET', headers, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }) - if (!resp.ok) await parseError(resp) + if (!resp.ok) await parseError(resp, sentToken) // 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input const text = await resp.text() return (text ? JSON.parse(text) : undefined) as T @@ -91,12 +105,13 @@ export async function rawFetch( const auth = useAuthStore() const headers: Record = { ...opts.headers } if (auth.token) headers.Authorization = `Bearer ${auth.token}` + const sentToken = auth.token const resp = await fetch(buildUrl(path, opts.query), { method: opts.method ?? 'GET', headers, body: opts.body, }) - if (!resp.ok) await parseError(resp) + if (!resp.ok) await parseError(resp, sentToken) return resp } diff --git a/src/api/settings.ts b/src/api/settings.ts index b8088ca..d18f07a 100644 --- a/src/api/settings.ts +++ b/src/api/settings.ts @@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise { return request('/settings/security') } -/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */ -export function updateSecuritySetting(body: SecuritySetting): Promise { +/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */ +export function updateSecuritySetting(patch: Partial): Promise { if (mockOn) { - Object.assign(mockSecuritySetting, body) + Object.assign(mockSecuritySetting, patch) return mocked({ ...mockSecuritySetting }, 400) } - return request('/settings/security', { method: 'PUT', body }) + return request('/settings/security', { method: 'PATCH', body: patch }) } export interface SystemLogParams { diff --git a/src/api/tasks.ts b/src/api/tasks.ts index 2a761f8..18a31e8 100644 --- a/src/api/tasks.ts +++ b/src/api/tasks.ts @@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise { return request(`/tasks/${id}/logs`, { query: { limit } }) } -export function runTask(id: number): Promise { - if (mockOn) { - const logs = mockTaskLogs[id] - return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000) - } +export function runTask(id: number): Promise<{ triggered: boolean }> { + if (mockOn) return mocked({ triggered: true }, 300) return request(`/tasks/${id}/run`, { method: 'POST' }) } diff --git a/src/api/tenant.ts b/src/api/tenant.ts index 1cdff71..1fb0653 100644 --- a/src/api/tenant.ts +++ b/src/api/tenant.ts @@ -10,7 +10,7 @@ import { mockUserDetails, mockUsers, } from './mock' -import { mockOn, mocked, request } from './request' +import { mockOn, mocked, rawFetch, request } from './request' import type { AuditEventsResult, IamUser, @@ -18,6 +18,7 @@ import type { IamUserDetail, IdentityProvider, IdentitySetting, + IdpIconUpload, LimitItem, LimitService, NotificationRecipients, @@ -298,7 +299,7 @@ export interface CreateIdpBody { name: string metadata: string description?: string - /** logo:data URI 或外链 URL */ + /** logo:http(s) 外链 URL(可先经 IdP 图标上传接口取得) */ iconUrl?: string nameIdFormat?: string /** 非空表示按断言属性映射;空则按 SAML NameID 映射 */ @@ -325,6 +326,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } }) } +/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */ +export async function uploadIdpIcon( + id: number, + file: File, + domainId?: string, +): Promise { + if (mockOn) + return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600) + const fd = new FormData() + fd.append('file', file, file.name) + const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, { + method: 'POST', + query: { domainId }, + body: fd, + }) + return (await resp.json()) as IdpIconUpload +} + +/** 删除尚未被 IdP 采用的身份域公共图片 */ +export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise { + if (mockOn) return mocked(undefined) + return request(`/oci-configs/${id}/idp-icons`, { + method: 'DELETE', + query: { domainId, fileName }, + }) +} + export function listIdps(id: number, domainId?: string): Promise { if (mockOn) return mocked(mockIdps) return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } }) diff --git a/src/components/instance/SerialConsolePanel.vue b/src/components/instance/SerialConsolePanel.vue index 31e1d79..b391c63 100644 --- a/src/components/instance/SerialConsolePanel.vue +++ b/src/components/instance/SerialConsolePanel.vue @@ -35,21 +35,34 @@ let ws: WebSocket | null = null let ro: ResizeObserver | null = null let sessionId = '' const encoder = new TextEncoder() +// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的 +// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示 +// B 实际连着 A,用户输入发往错误实例 +let openSeq = 0 async function open() { + const seq = ++openSeq phase.value = 'creating' error.value = '' height.value = Math.max(280, Math.round(window.innerHeight * 0.42)) + let created = '' try { const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region) - sessionId = res.sessionId + created = res.sessionId } catch (e) { + if (seq !== openSeq) return phase.value = 'closed' error.value = e instanceof Error ? e.message : '创建串行会话失败' return } + if (seq !== openSeq) { + void deleteConsoleSession(created).catch(() => undefined) + return + } + sessionId = created phase.value = 'connecting' await nextTick() + if (seq !== openSeq) return mountTerm() connectWs() } @@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) { } function teardown() { + openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话 ro?.disconnect() ro = null if (ws) { diff --git a/src/components/logs/AiCallLogPanel.vue b/src/components/logs/AiCallLogPanel.vue index 0c09272..8334c2a 100644 --- a/src/components/logs/AiCallLogPanel.vue +++ b/src/components/logs/AiCallLogPanel.vue @@ -41,16 +41,22 @@ const pagination = reactive({ }, }) +// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时 +// 不得覆盖已切换的新页 +let loadSeq = 0 + async function load(silent = false) { + const seq = ++loadSeq if (!silent) loading.value = true try { const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize }) + if (seq !== loadSeq) return rows.value = data.items pagination.itemCount = data.total } catch { // 自动刷新失败静默,手动路径由全局兜底 } finally { - if (!silent) loading.value = false + if (!silent && seq === loadSeq) loading.value = false } } @@ -106,12 +112,15 @@ const showDetail = ref(false) // ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ---- const contentLog = ref(null) +// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情 +let contentSeq = 0 async function loadContent(callLogId: number) { + const seq = ++contentSeq contentLog.value = null try { const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId }) - contentLog.value = items[0] ?? null + if (seq === contentSeq) contentLog.value = items[0] ?? null } catch { // 正文加载失败不阻塞元数据详情 } diff --git a/src/components/logs/LogEventPanel.vue b/src/components/logs/LogEventPanel.vue index 9317aaa..7374853 100644 --- a/src/components/logs/LogEventPanel.vue +++ b/src/components/logs/LogEventPanel.vue @@ -64,7 +64,12 @@ const pagination = reactive({ }, }) +// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时 +// 不得覆盖已切换的新页 +let loadSeq = 0 + async function load(silent = false) { + const seq = ++loadSeq if (!silent) loading.value = true try { const data = await listLogEvents({ @@ -72,12 +77,13 @@ async function load(silent = false) { pageSize: pagination.pageSize, cfgId: cfgFilter.value ?? undefined, }) + if (seq !== loadSeq) return rows.value = data.items pagination.itemCount = data.total } catch (e) { if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败') } finally { - if (!silent) loading.value = false + if (!silent && seq === loadSeq) loading.value = false } } diff --git a/src/components/logs/SystemLogPanel.vue b/src/components/logs/SystemLogPanel.vue index c143ba3..05e1a28 100644 --- a/src/components/logs/SystemLogPanel.vue +++ b/src/components/logs/SystemLogPanel.vue @@ -48,7 +48,12 @@ const pagination = reactive({ }, }) +// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时 +// 不得覆盖已切换的新页 +let loadSeq = 0 + async function load(silent = false) { + const seq = ++loadSeq if (!silent) loading.value = true try { const data = await listSystemLogs({ @@ -56,12 +61,13 @@ async function load(silent = false) { pageSize: pagination.pageSize, keyword: applied.value || undefined, }) + if (seq !== loadSeq) return rows.value = data.items pagination.itemCount = data.total } catch (e) { if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败') } finally { - if (!silent) loading.value = false + if (!silent && seq === loadSeq) loading.value = false } } diff --git a/src/components/objectstorage/ObjectBrowser.vue b/src/components/objectstorage/ObjectBrowser.vue index a8e61ab..33e99f3 100644 --- a/src/components/objectstorage/ObjectBrowser.vue +++ b/src/components/objectstorage/ObjectBrowser.vue @@ -69,9 +69,23 @@ const result = useAsync(async () => { }) }, false) -watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), { - immediate: true, -}) +// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置; +// 层内进目录/翻页只重查列表 +watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope()) +watch([prefix, cursor], () => void result.run(), { immediate: true }) + +/** 作用域变化后重置路径、游标、选中项与弹窗再重查:旧列表配新作用域时, + * 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */ +function resetScope() { + prefix.value = '' + filterInput.value = '' + cursorStack.value = [] + cursor.value = '' + checkedKeys.value = [] + showPar.value = false + showViewer.value = false + void result.run() +} /** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */ const POLL_MS = 30_000 @@ -220,14 +234,25 @@ async function cleanupPar(parId: string) { // ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ---- const DOWN_STREAM_LIMIT = 256 * 1024 * 1024 +/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */ +function willBrowserHandle(size: number): boolean { + return !size || size > DOWN_STREAM_LIMIT +} + function download(name: string, size?: number) { const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0 const task = newTask('down', name.split('/').pop() || name, () => download(name, known)) - return doDownload(task, name, known) + // 预开窗口必须在点击手势内同步进行:PAR 签发后再 window.open 已脱离手势, + // 会被弹窗策略拦截(重试按钮点击同样是手势,拦截后可恢复) + const preWin = willBrowserHandle(known) ? window.open('', '_blank') : null + return doDownload(task, name, known, preWin) } -async function doDownload(task: TransferTask, name: string, size: number) { - if (!props.cfgId) return +async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) { + if (!props.cfgId) { + closeIfBlank(preWin) + return + } try { const par = await createPar(props.cfgId, props.bucket, { region: props.region, @@ -235,34 +260,50 @@ async function doDownload(task: TransferTask, name: string, size: number) { accessType: 'ObjectRead', expiresHours: 1, }) - const cleanable = await transferDown(task, par.fullUrl!, size) + const cleanable = await transferDown(task, par.fullUrl!, size, preWin) task.status = 'done' task.percent = 100 // 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理 if (cleanable) void cleanupPar(par.id) else parPanel.value?.refresh() } catch (err) { + // 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页 + closeIfBlank(preWin) if ((err as DOMException)?.name === 'AbortError') return task.status = 'error' task.error = err instanceof Error ? err.message : '下载失败' } } +/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */ +function closeIfBlank(preWin: Window | null) { + if (preWin && !preWin.closed) preWin.close() +} + /** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */ -async function transferDown(task: TransferTask, url: string, size: number): Promise { - if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url) +async function transferDown( + task: TransferTask, + url: string, + size: number, + preWin: Window | null, +): Promise { + if (willBrowserHandle(size)) return browserOpen(task, url, preWin) try { const blob = await fetchWithProgress(task, url, size) saveBlob(blob, task.name) return true } catch (err) { if ((err as DOMException)?.name === 'AbortError') throw err - return browserOpen(task, url) + // 流式失败回退浏览器接管:此处已脱离手势无预开窗口,被拦会如实报错 + return browserOpen(task, url, null) } } -function browserOpen(task: TransferTask, url: string): boolean { - window.open(url, '_blank') +/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */ +function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean { + const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank') + if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试') + if (win === preWin) win.location.replace(url) task.note = '已交由浏览器下载' return false } @@ -360,12 +401,19 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours: }) const parUrl = ref('') const parIsBucket = computed(() => !parForm.objectName) -/** 有效期快捷项;也可直接输入 1-720 任意小时数 */ +const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24 +const parExpiresValid = computed(() => { + const hours = parForm.expiresHours + return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS +}) +/** 有效期快捷项;也可直接输入 1 至 876000 小时 */ const QUICK_HOURS = [ { label: '1 小时', value: 1 }, { label: '1 天', value: 24 }, { label: '7 天', value: 168 }, { label: '30 天', value: 720 }, + { label: '1 年', value: 8760 }, + { label: '10 年', value: 87600 }, ] const parPanel = ref | null>(null) @@ -380,14 +428,15 @@ function openParModal(objectName: string) { } async function doCreatePar() { - if (!props.cfgId || !parForm.expiresHours) return + const expiresHours = parForm.expiresHours + if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return parBusy.value = true try { const par = await createPar(props.cfgId, props.bucket, { region: props.region, objectName: parForm.objectName, accessType: parForm.accessType, - expiresHours: parForm.expiresHours, + expiresHours, }) parUrl.value = par.fullUrl ?? '' parPanel.value?.refresh() @@ -848,7 +897,7 @@ const columns = computed>(() => [ :title="parIsBucket ? '分享桶' : '分享对象'" :width="460" :submitting="parBusy" - :submit-disabled="!!parUrl || !parForm.expiresHours" + :submit-disabled="!!parUrl || !parExpiresValid" submit-text="签发" @update:show="showPar = $event" @submit="doCreatePar" @@ -862,12 +911,12 @@ const columns = computed>(() => [ 读写 - +
{ - if (!blockedReason.value) void loadContent() + // detail 是共享 useAsync:换对象后旧 then 链仍会执行,按代次拦下 + if (seq === contentSeq && !blockedReason.value) void loadContent() }) }, ) @@ -145,19 +147,25 @@ function revokeImage() { imageUrl.value = '' } +// 内容加载代次:换对象/关闭即自增,旧对象的迟到响应按代次丢弃—— +// 否则 A 的正文与 ETag 会顶着 B 的标题展示,编辑保存还会拿错基线 +let contentSeq = 0 + async function loadContent() { if (!props.cfgId || loading.value) return + const seq = ++contentSeq loading.value = true loadError.value = '' try { const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region) + if (seq !== contentSeq) return contentEtag.value = c.etag contentType.value = c.contentType await applyContent(c.blob) } catch (e) { - loadError.value = e instanceof Error ? e.message : '内容加载失败' + if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败' } finally { - loading.value = false + if (seq === contentSeq) loading.value = false } } diff --git a/src/components/objectstorage/ParPanel.vue b/src/components/objectstorage/ParPanel.vue index 53ccfe2..0b7d45d 100644 --- a/src/components/objectstorage/ParPanel.vue +++ b/src/components/objectstorage/ParPanel.vue @@ -35,7 +35,8 @@ const pars = useAsync(async () => { const items = computed(() => pars.data.value?.items ?? []) watch( - [() => props.cfgId, () => props.bucket], + // region 必须在依赖里:切区域后旧列表配新区域,撤销会删错区域的同名 PAR + [() => props.cfgId, () => props.region, () => props.bucket], () => { cursorStack.value = [] cursor.value = '' diff --git a/src/components/settings/AccountSecurityCard.vue b/src/components/settings/AccountSecurityCard.vue index 9798901..3500684 100644 --- a/src/components/settings/AccountSecurityCard.vue +++ b/src/components/settings/AccountSecurityCard.vue @@ -322,23 +322,8 @@ const secretConfigured = computed(() => { return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet }) -/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */ -function oauthPayloadWith(patch: Partial): UpdateOAuthRequest { - const cfg = oauthCfg.data.value - return { - oidcIssuer: cfg?.oidcIssuer ?? '', - oidcClientId: cfg?.oidcClientId ?? '', - oidcDisplayName: cfg?.oidcDisplayName ?? '', - oidcDisabled: cfg?.oidcDisabled ?? false, - githubClientId: cfg?.githubClientId ?? '', - githubDisplayName: cfg?.githubDisplayName ?? '', - githubDisabled: cfg?.githubDisabled ?? false, - ...patch, - } -} - -/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */ -function providerFormPatch(): Partial { +/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */ +function providerFormPatch(): UpdateOAuthRequest { const name = pForm.displayName.trim() const id = pForm.clientId.trim() if (pForm.provider === 'oidc') @@ -358,7 +343,7 @@ function providerFormPatch(): Partial { async function saveProvider() { savingOauth.value = true try { - await updateOAuthSettings(oauthPayloadWith(providerFormPatch())) + await updateOAuthSettings(providerFormPatch()) message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`) showProviderModal.value = false void oauthCfg.run({ silent: true }) @@ -374,7 +359,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) { savingOauth.value = true try { const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled } - await updateOAuthSettings(oauthPayloadWith(patch)) + await updateOAuthSettings(patch) message.success(`「${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`) void oauthCfg.run({ silent: true }) } catch (e) { @@ -393,7 +378,7 @@ async function deleteProvider(p: ProviderType) { p === 'oidc' ? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false } : { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false } - await updateOAuthSettings(oauthPayloadWith(patch)) + await updateOAuthSettings(patch) message.success(`登录方式「${name}」已删除`) void oauthCfg.run({ silent: true }) } catch (e) { @@ -570,7 +555,7 @@ async function deleteProvider(p: ProviderType) { {{ row.disabled ? '已禁用' : '启用中' }} diff --git a/src/components/task/TaskFormModal.vue b/src/components/task/TaskFormModal.vue index ad8c08a..0591a03 100644 --- a/src/components/task/TaskFormModal.vue +++ b/src/components/task/TaskFormModal.vue @@ -70,7 +70,9 @@ watch( if (t.type === 'snatch') { const inst = (p.instance ?? {}) as Record form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null - form.count = typeof p.count === 'number' ? p.count : 1 + // 表单编辑的是目标台数:优先 totalCount,旧任务缺省回退剩余台数 + const target = typeof p.totalCount === 'number' ? p.totalCount : p.count + form.count = typeof target === 'number' ? target : 1 form.displayName = String(inst.displayName ?? '') form.snatchRegion = typeof inst.region === 'string' ? inst.region : '' form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : '' diff --git a/src/components/tenant/AuditLogTab.vue b/src/components/tenant/AuditLogTab.vue index ff21e43..76c82bb 100644 --- a/src/components/tenant/AuditLogTab.vue +++ b/src/components/tenant/AuditLogTab.vue @@ -182,7 +182,11 @@ function rowProps(row: AuditEvent) { } } +// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情 +let rawSeq = 0 + async function loadRaw(row: AuditEvent) { + const seq = ++rawSeq detailRaw.value = null rawGone.value = false if (!row.eventId || !row.eventTime) { @@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) { eventId: row.eventId, eventTime: row.eventTime, }) - detailRaw.value = raw + if (seq === rawSeq) detailRaw.value = raw } catch { - rawGone.value = true + if (seq === rawSeq) rawGone.value = true } finally { - rawLoading.value = false + if (seq === rawSeq) rawLoading.value = false } } diff --git a/src/components/tenant/IdpFormModal.vue b/src/components/tenant/IdpFormModal.vue index dd303d8..acb5d98 100644 --- a/src/components/tenant/IdpFormModal.vue +++ b/src/components/tenant/IdpFormModal.vue @@ -1,12 +1,19 @@