4 Commits
Author SHA1 Message Date
wangdefa d0fbecbd43 发布 v0.8.2
CI / test (push) Successful in 45s
Release / release (push) Successful in 44s
2026-07-22 19:47:57 +08:00
wangdefa b0a76a1c96 用户Tab:API Keys 分栏弹窗,重置密码加二次确认
CI / test (push) Successful in 46s
2026-07-22 19:38:21 +08:00
wangdefa f85e457b9b 发布 v0.8.0
CI / test (push) Successful in 43s
Release / release (push) Successful in 45s
2026-07-22 17:09:33 +08:00
wangdefa 119f60516c 修复全量审查问题;接入vitest;精简IdP图标逻辑
CI / test (push) Successful in 46s
2026-07-22 16:51:45 +08:00
42 changed files with 1514 additions and 822 deletions
+2 -1
View File
@@ -1,6 +1,5 @@
name: CI name: CI
# 仅分支推送触发:tag 推送交给 release.yml;solo 开发不走 PR,去掉 pull_request 避免同一提交双跑
on: on:
push: push:
branches: ["**"] branches: ["**"]
@@ -20,5 +19,7 @@ jobs:
run: npx eslint . run: npx eslint .
- name: 类型检查 - name: 类型检查
run: npm run typecheck run: npm run typecheck
- name: 单元测试
run: npm run test
- name: 构建 - name: 构建
run: npm run build run: npm run build
+2
View File
@@ -19,5 +19,7 @@ jobs:
run: npx eslint . run: npx eslint .
- name: 类型检查 - name: 类型检查
run: npm run typecheck run: npm run typecheck
- name: 单元测试
run: npm run test
- name: 构建 - name: 构建
run: npm run build run: npm run build
+1 -1
View File
@@ -1 +1 @@
0.6.6 0.6.7
+10
View File
@@ -9,3 +9,13 @@
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。 - 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。 - 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 联动父级刷新)。 - 大列表禁止全量拉取(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 下载)
+3 -2
View File
@@ -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) - [ ] 读过 [Directory Structure](./directory-structure.md),文件放对层(views / components / composables / api / stores / types)
- [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex - [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex
- [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应) - [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应)
- [ ] 出 UI 设计稿前读 `../guides/design-workflow.md` - [ ] 出 UI 设计稿前读 `../../guides/design-workflow.md`
## Quality Check ## Quality Check
@@ -37,6 +37,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
```bash ```bash
npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier
npm run typecheck # vue-tsc --noEmit npm run typecheck # vue-tsc --noEmit
npm run test # vitest(happy-dom),*.test.ts 与被测模块同目录
npm run build # 类型检查 + 产物构建 npm run build # 类型检查 + 产物构建
``` ```
@@ -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.
@@ -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 "<command>.*"`
- [ ] 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 "<command>.*"`
- [ ] 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.
+12 -86
View File
@@ -1,98 +1,24 @@
# Thinking Guides # 项目 Guides
> **Purpose**: Expand your thinking to catch things you might not have considered. > 跨仓库的工作纪律与流程约定;各层编码规范见 `oci-portal/backend/` 与 `oci-portal-dash/frontend/`。
---
## 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**.
---
## Available Guides ## Available Guides
| Guide | Purpose | When to Use | | Guide | 内容 | 何时读 |
|-------|---------|-------------| |-------|------|--------|
| [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 |
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 | | [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) **核验规则**:每条 CRITICAL/WARNING 结论必须对照实际代码核验后再排优先级;AI 评审按 ~35% 误报率做预算。
- [ ] 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.
--- ---
## Pre-Modification Rule (CRITICAL) 2026-07 已删除上游模板自带的两个通用 thinking guide(code-reuse / cross-layer):内容为通用常识与 Trellis 自身开发案例,与本项目无关。项目教训一律直接沉淀到对应层的规范文件,不建通用思维文档。
> **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.
+31
View File
@@ -2,6 +2,37 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.8.2]
### Added
- IAM 用户新增 API Keys 分栏管理弹窗,支持查看指纹与 OCI CLI 配置、创建(每用户最多 3 把)和逐把删除;新建私钥仅在本次弹窗中可下载,当前签名用户可将新 Key 切换为面板签名凭据
### Changed
- IAM 用户重置密码新增二次确认,避免误触后立即生成一次性新密码
## [0.8.1]
### Added
- SAML IdP 创建支持将不超过 1 MB 的 Logo 上传到身份域公共图片存储并预览;替换或放弃表单时会尽力清理未采用图标
### Changed
- 对象存储 PAR 分享链接有效期上限由 30 天扩展至 100 年,并新增 1 年 / 10 年快捷选项
- 总览成本改为按币种分开展示:主币种保留趋势图,其他币种单列合计,不再跨币种相加
### Fixed
- 修复 OAuth 绑定回跳与会话过期处理:优先接收新令牌,旧请求迟到的 401 不再清除新会话;当前会话失效时保留原路径跳转登录
- 修复安全设置与 OAuth 登录方式并发保存时互相回滚或复活已删除配置,改为字段级更新并合并在途修改
- 修复对象存储切换租户 / 区域 / 桶后仍残留旧路径、游标、选中项及 PAR 列表,避免对同名资源执行错误操作;快速切换对象时旧内容 / ETag 不再覆盖当前预览
- 修复未知大小及大文件下载因异步打开窗口被浏览器拦截却仍显示成功;现预开下载窗口,拦截时明确提示并支持重试
- 修复快速切换实例时旧串行控制台会话接入当前面板,以及日志翻页 / 自动刷新 / 连续查看详情时迟到响应覆盖当前数据
- 修复抢机任务编辑时把剩余台数误作目标台数,并避免慢接口下任务列表轮询请求重叠
- 修复创建 SAML IdP 时身份设置未按所选身份域读取、异步响应串单和重复提交;JIT 后续配置状态不确定时改为显示关联请求警告
## [0.8.0] ## [0.8.0]
### Added ### Added
+1 -1
View File
@@ -23,7 +23,7 @@ oci-portal-dashOCI Portal 前端(Vue 3 + Vite + TypeScript + Naive UI + Tai
## 质量门(提交前必过) ## 质量门(提交前必过)
```bash ```bash
npm run lint && npm run typecheck && npm run build npm run lint && npm run typecheck && npm run test && npm run build
``` ```
## 文档同步 ## 文档同步
+1
View File
@@ -18,6 +18,7 @@ npm run dev # http://localhost:5173,默认 mock 模式开箱可视
```bash ```bash
npm run lint # ESLint + Prettier(带 --fix) npm run lint # ESLint + Prettier(带 --fix)
npm run typecheck # vue-tsc --noEmit npm run typecheck # vue-tsc --noEmit
npm run test # vitest 单元测试(happy-dom 环境)
npm run build # 类型检查 + 产物构建到 dist/ npm run build # 类型检查 + 产物构建到 dist/
``` ```
+459 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"version": "0.2.0", "version": "0.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"version": "0.2.0", "version": "0.8.2",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "^6.0.1",
@@ -50,11 +50,13 @@
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"happy-dom": "^20.11.0",
"prettier": "^3.9.5", "prettier": "^3.9.5",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.10",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
}, },
@@ -3412,6 +3414,13 @@
"win32" "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": { "node_modules/@tailwindcss/node": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -3719,6 +3728,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3772,6 +3799,23 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1", "version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
@@ -4029,6 +4073,129 @@
"vue": "^3.2.25" "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": { "node_modules/@volar/language-core": {
"version": "2.4.15", "version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -4497,6 +4664,16 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -4695,6 +4872,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -4776,6 +4966,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5300,6 +5500,13 @@
"node": ">= 0.4" "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": { "node_modules/es-object-atoms": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "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==", "integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6266,6 +6483,25 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/has-bigints": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -7631,6 +7867,20 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7780,6 +8030,13 @@
"node": "20 || >=22" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8417,6 +8674,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -8480,6 +8744,20 @@
"node": ">=0.10.0" "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": { "node_modules/stop-iteration-iterator": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8750,6 +9028,23 @@
"node": ">=10" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -8798,6 +9093,16 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "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" "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": { "node_modules/vooks": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz", "resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
@@ -9448,6 +9856,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9553,6 +9971,23 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -9824,6 +10259,28 @@
"workbox-core": "7.4.1" "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": { "node_modules/xml-name-validator": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+4 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"private": true, "private": true,
"version": "0.8.0", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"test": "vitest run",
"format": "prettier --write src/" "format": "prettier --write src/"
}, },
"dependencies": { "dependencies": {
@@ -54,11 +55,13 @@
"@vue/tsconfig": "^0.7.0", "@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0", "eslint-plugin-vue": "^9.32.0",
"happy-dom": "^20.11.0",
"prettier": "^3.9.5", "prettier": "^3.9.5",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "~5.7.3", "typescript": "~5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.10",
"vue-tsc": "^2.2.0" "vue-tsc": "^2.2.0"
} }
} }
+14 -17
View File
@@ -129,9 +129,7 @@ export function unbindIdentity(id: number): Promise<SessionRefresh> {
// ---- OAuth provider 配置(设置页) ---- // ---- OAuth provider 配置(设置页) ----
export function getOAuthSettings(): Promise<OAuthSettings> { const mockOauth: OAuthSettings = {
if (mockOn)
return mocked({
oidcIssuer: '', oidcIssuer: '',
oidcClientId: '', oidcClientId: '',
oidcSecretSet: false, oidcSecretSet: false,
@@ -141,22 +139,21 @@ export function getOAuthSettings(): Promise<OAuthSettings> {
githubSecretSet: false, githubSecretSet: false,
githubDisplayName: '', githubDisplayName: '',
githubDisabled: false, githubDisabled: false,
}) }
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn) return mocked({ ...mockOauth })
return request('/settings/oauth') return request('/settings/oauth')
} }
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> { export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn) if (mockOn) {
return mocked({ const { oidcClientSecret, githubClientSecret, ...rest } = body
oidcIssuer: body.oidcIssuer, Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
oidcClientId: body.oidcClientId, if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
oidcSecretSet: !!body.oidcClientSecret, if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
oidcDisplayName: body.oidcDisplayName, return mocked({ ...mockOauth })
oidcDisabled: body.oidcDisabled, }
githubClientId: body.githubClientId, return request('/settings/oauth', { method: 'PATCH', body })
githubSecretSet: !!body.githubClientSecret,
githubDisplayName: body.githubDisplayName,
githubDisabled: body.githubDisabled,
})
return request('/settings/oauth', { method: 'PUT', body })
} }
+66
View File
@@ -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<typeof vi.spyOn>
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()
})
})
+19 -4
View File
@@ -32,7 +32,7 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path return s ? `${BASE}${path}?${s}` : BASE + path
} }
async function parseError(resp: Response): Promise<never> { async function parseError(resp: Response, sentToken: string): Promise<never> {
let message = `请求失败(${resp.status}` let message = `请求失败(${resp.status}`
let ociCode: string | undefined let ociCode: string | undefined
try { try {
@@ -54,7 +54,7 @@ async function parseError(resp: Response): Promise<never> {
} catch { } catch {
/* 非 JSON 响应体,保留默认消息 */ /* 非 JSON 响应体,保留默认消息 */
} }
if (resp.status === 401) useAuthStore().logout() if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示 // 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') { if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked') location.assign('/blocked')
@@ -62,17 +62,31 @@ async function parseError(resp: Response): Promise<never> {
throw new ApiError(resp.status, message, ociCode) 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 */ /** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> { export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const auth = useAuthStore() const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers } const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}` if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), { const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET', method: opts.method ?? 'GET',
headers, headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body), 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 // 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text() const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T return (text ? JSON.parse(text) : undefined) as T
@@ -91,12 +105,13 @@ export async function rawFetch(
const auth = useAuthStore() const auth = useAuthStore()
const headers: Record<string, string> = { ...opts.headers } const headers: Record<string, string> = { ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}` if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), { const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET', method: opts.method ?? 'GET',
headers, headers,
body: opts.body, body: opts.body,
}) })
if (!resp.ok) await parseError(resp) if (!resp.ok) await parseError(resp, sentToken)
return resp return resp
} }
+4 -4
View File
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
return request('/settings/security') return request('/settings/security')
} }
/** 保存安全设置,返回最新;越界或非法后端返回 400,保存后立即生效 */ /** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> { export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
if (mockOn) { if (mockOn) {
Object.assign(mockSecuritySetting, body) Object.assign(mockSecuritySetting, patch)
return mocked({ ...mockSecuritySetting }, 400) return mocked({ ...mockSecuritySetting }, 400)
} }
return request('/settings/security', { method: 'PUT', body }) return request('/settings/security', { method: 'PATCH', body: patch })
} }
export interface SystemLogParams { export interface SystemLogParams {
+2 -5
View File
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
return request(`/tasks/${id}/logs`, { query: { limit } }) return request(`/tasks/${id}/logs`, { query: { limit } })
} }
export function runTask(id: number): Promise<TaskLog> { export function runTask(id: number): Promise<{ triggered: boolean }> {
if (mockOn) { if (mockOn) return mocked({ triggered: true }, 300)
const logs = mockTaskLogs[id]
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
}
return request(`/tasks/${id}/run`, { method: 'POST' }) return request(`/tasks/${id}/run`, { method: 'POST' })
} }
+80 -10
View File
@@ -10,14 +10,16 @@ import {
mockUserDetails, mockUserDetails,
mockUsers, mockUsers,
} from './mock' } from './mock'
import { mockOn, mocked, request } from './request' import { mockOn, mocked, rawFetch, request } from './request'
import type { import type {
AuditEventsResult, AuditEventsResult,
CreatedApiKey,
IamUser, IamUser,
IdentityDomain, IdentityDomain,
IamUserDetail, IamUserDetail,
IdentityProvider, IdentityProvider,
IdentitySetting, IdentitySetting,
IdpIconUpload,
LimitItem, LimitItem,
LimitService, LimitService,
NotificationRecipients, NotificationRecipients,
@@ -25,6 +27,7 @@ import type {
SignOnRule, SignOnRule,
SubscriptionDetail, SubscriptionDetail,
SubscriptionSummary, SubscriptionSummary,
UserApiKey,
} from '@/types/api' } from '@/types/api'
// ---- 配额 ---- // ---- 配额 ----
@@ -168,15 +171,55 @@ export function clearUserMfa(id: number, userId: string, domainId?: string): Pro
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } }) return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } })
} }
export function deleteUserApiKeys( // ---- 用户 API 签名 Key ----
id: number,
userId: string, const mockCreatedKey: CreatedApiKey = {
includeCurrent = false, fingerprint: '11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff',
): Promise<{ deletedApiKeys: number }> { privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMOCKMOCKMOCK\n-----END RSA PRIVATE KEY-----\n',
if (mockOn) return mocked({ deletedApiKeys: 1 }) configIni:
return request(`/oci-configs/${id}/users/${userId}/api-keys`, { '[DEFAULT]\nuser=ocid1.user.oc1..mock\nfingerprint=11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff\ntenancy=ocid1.tenancy.oc1..mock\nregion=ap-tokyo-1\nkey_file=~/.oci/oci_api_key.pem\n',
}
export function listUserApiKeys(id: number, userId: string): Promise<{ items: UserApiKey[] }> {
if (mockOn)
return mocked({
items: [
{
fingerprint: 'aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99',
timeCreated: '2026-07-01T08:00:00Z',
isCurrent: true,
configIni: mockCreatedKey.configIni,
},
{
fingerprint: '99:88:77:66:55:44:33:22:11:00:ff:ee:dd:cc:bb:aa',
timeCreated: '2026-07-10T08:00:00Z',
isCurrent: false,
configIni: mockCreatedKey.configIni,
},
],
})
return request(`/oci-configs/${id}/users/${userId}/api-keys`)
}
/** 后端生成密钥对并上传公钥;私钥仅本次返回 */
export function addUserApiKey(id: number, userId: string): Promise<CreatedApiKey> {
if (mockOn) return mocked({ ...mockCreatedKey })
return request(`/oci-configs/${id}/users/${userId}/api-keys`, { method: 'POST' })
}
export function deleteUserApiKey(id: number, userId: string, fingerprint: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/users/${userId}/api-keys/${encodeURIComponent(fingerprint)}`, {
method: 'DELETE', method: 'DELETE',
query: { includeCurrent }, })
}
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传 */
export function activateApiKey(id: number, fingerprint: string, privateKey: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/activate-api-key`, {
method: 'POST',
body: { fingerprint, privateKey },
}) })
} }
@@ -298,7 +341,7 @@ export interface CreateIdpBody {
name: string name: string
metadata: string metadata: string
description?: string description?: string
/** logodata URI 或外链 URL */ /** logohttp(s) 外链 URL(可先经 IdP 图标上传接口取得) */
iconUrl?: string iconUrl?: string
nameIdFormat?: string nameIdFormat?: string
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */ /** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
@@ -325,6 +368,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } }) 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<IdpIconUpload> {
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<void> {
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<IdentityProvider[]> { export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
if (mockOn) return mocked(mockIdps) if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } }) return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
+15 -1
View File
@@ -35,21 +35,34 @@ let ws: WebSocket | null = null
let ro: ResizeObserver | null = null let ro: ResizeObserver | null = null
let sessionId = '' let sessionId = ''
const encoder = new TextEncoder() const encoder = new TextEncoder()
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
// B 实际连着 A,用户输入发往错误实例
let openSeq = 0
async function open() { async function open() {
const seq = ++openSeq
phase.value = 'creating' phase.value = 'creating'
error.value = '' error.value = ''
height.value = Math.max(280, Math.round(window.innerHeight * 0.42)) height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
let created = ''
try { try {
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region) const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
sessionId = res.sessionId created = res.sessionId
} catch (e) { } catch (e) {
if (seq !== openSeq) return
phase.value = 'closed' phase.value = 'closed'
error.value = e instanceof Error ? e.message : '创建串行会话失败' error.value = e instanceof Error ? e.message : '创建串行会话失败'
return return
} }
if (seq !== openSeq) {
void deleteConsoleSession(created).catch(() => undefined)
return
}
sessionId = created
phase.value = 'connecting' phase.value = 'connecting'
await nextTick() await nextTick()
if (seq !== openSeq) return
mountTerm() mountTerm()
connectWs() connectWs()
} }
@@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) {
} }
function teardown() { function teardown() {
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
ro?.disconnect() ro?.disconnect()
ro = null ro = null
if (ws) { if (ws) {
+11 -2
View File
@@ -41,16 +41,22 @@ const pagination = reactive({
}, },
}) })
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) { async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true if (!silent) loading.value = true
try { try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize }) const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
if (seq !== loadSeq) return
rows.value = data.items rows.value = data.items
pagination.itemCount = data.total pagination.itemCount = data.total
} catch { } catch {
// 自动刷新失败静默,手动路径由全局兜底 // 自动刷新失败静默,手动路径由全局兜底
} finally { } finally {
if (!silent) loading.value = false if (!silent && seq === loadSeq) loading.value = false
} }
} }
@@ -106,12 +112,15 @@ const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ---- // ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null) const contentLog = ref<AiContentLog | null>(null)
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
let contentSeq = 0
async function loadContent(callLogId: number) { async function loadContent(callLogId: number) {
const seq = ++contentSeq
contentLog.value = null contentLog.value = null
try { try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId }) const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
contentLog.value = items[0] ?? null if (seq === contentSeq) contentLog.value = items[0] ?? null
} catch { } catch {
// 正文加载失败不阻塞元数据详情 // 正文加载失败不阻塞元数据详情
} }
+7 -1
View File
@@ -64,7 +64,12 @@ const pagination = reactive({
}, },
}) })
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) { async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true if (!silent) loading.value = true
try { try {
const data = await listLogEvents({ const data = await listLogEvents({
@@ -72,12 +77,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
cfgId: cfgFilter.value ?? undefined, cfgId: cfgFilter.value ?? undefined,
}) })
if (seq !== loadSeq) return
rows.value = data.items rows.value = data.items
pagination.itemCount = data.total pagination.itemCount = data.total
} catch (e) { } catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败') if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
} finally { } finally {
if (!silent) loading.value = false if (!silent && seq === loadSeq) loading.value = false
} }
} }
+7 -1
View File
@@ -48,7 +48,12 @@ const pagination = reactive({
}, },
}) })
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) { async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true if (!silent) loading.value = true
try { try {
const data = await listSystemLogs({ const data = await listSystemLogs({
@@ -56,12 +61,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize, pageSize: pagination.pageSize,
keyword: applied.value || undefined, keyword: applied.value || undefined,
}) })
if (seq !== loadSeq) return
rows.value = data.items rows.value = data.items
pagination.itemCount = data.total pagination.itemCount = data.total
} catch (e) { } catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败') if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
} finally { } finally {
if (!silent) loading.value = false if (!silent && seq === loadSeq) loading.value = false
} }
} }
+67 -18
View File
@@ -69,9 +69,23 @@ const result = useAsync(async () => {
}) })
}, false) }, false)
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), { // 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置;
immediate: true, // 层内进目录/翻页只重查列表
}) 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)时后台静默轮询,状态落定自动停 */ /** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
const POLL_MS = 30_000 const POLL_MS = 30_000
@@ -220,14 +234,25 @@ async function cleanupPar(parId: string) {
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ---- // ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024 const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
function willBrowserHandle(size: number): boolean {
return !size || size > DOWN_STREAM_LIMIT
}
function download(name: string, size?: number) { function download(name: string, size?: number) {
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0 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)) 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) { async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) {
if (!props.cfgId) return if (!props.cfgId) {
closeIfBlank(preWin)
return
}
try { try {
const par = await createPar(props.cfgId, props.bucket, { const par = await createPar(props.cfgId, props.bucket, {
region: props.region, region: props.region,
@@ -235,34 +260,50 @@ async function doDownload(task: TransferTask, name: string, size: number) {
accessType: 'ObjectRead', accessType: 'ObjectRead',
expiresHours: 1, expiresHours: 1,
}) })
const cleanable = await transferDown(task, par.fullUrl!, size) const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
task.status = 'done' task.status = 'done'
task.percent = 100 task.percent = 100
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理 // 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
if (cleanable) void cleanupPar(par.id) if (cleanable) void cleanupPar(par.id)
else parPanel.value?.refresh() else parPanel.value?.refresh()
} catch (err) { } catch (err) {
// 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页
closeIfBlank(preWin)
if ((err as DOMException)?.name === 'AbortError') return if ((err as DOMException)?.name === 'AbortError') return
task.status = 'error' task.status = 'error'
task.error = err instanceof Error ? err.message : '下载失败' task.error = err instanceof Error ? err.message : '下载失败'
} }
} }
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
function closeIfBlank(preWin: Window | null) {
if (preWin && !preWin.closed) preWin.close()
}
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */ /** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> { async function transferDown(
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url) task: TransferTask,
url: string,
size: number,
preWin: Window | null,
): Promise<boolean> {
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
try { try {
const blob = await fetchWithProgress(task, url, size) const blob = await fetchWithProgress(task, url, size)
saveBlob(blob, task.name) saveBlob(blob, task.name)
return true return true
} catch (err) { } catch (err) {
if ((err as DOMException)?.name === 'AbortError') throw 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 = '已交由浏览器下载' task.note = '已交由浏览器下载'
return false return false
} }
@@ -360,12 +401,19 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
}) })
const parUrl = ref('') const parUrl = ref('')
const parIsBucket = computed(() => !parForm.objectName) 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 = [ const QUICK_HOURS = [
{ label: '1 小时', value: 1 }, { label: '1 小时', value: 1 },
{ label: '1 天', value: 24 }, { label: '1 天', value: 24 },
{ label: '7 天', value: 168 }, { label: '7 天', value: 168 },
{ label: '30 天', value: 720 }, { label: '30 天', value: 720 },
{ label: '1 年', value: 8760 },
{ label: '10 年', value: 87600 },
] ]
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null) const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
@@ -380,14 +428,15 @@ function openParModal(objectName: string) {
} }
async function doCreatePar() { async function doCreatePar() {
if (!props.cfgId || !parForm.expiresHours) return const expiresHours = parForm.expiresHours
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
parBusy.value = true parBusy.value = true
try { try {
const par = await createPar(props.cfgId, props.bucket, { const par = await createPar(props.cfgId, props.bucket, {
region: props.region, region: props.region,
objectName: parForm.objectName, objectName: parForm.objectName,
accessType: parForm.accessType, accessType: parForm.accessType,
expiresHours: parForm.expiresHours, expiresHours,
}) })
parUrl.value = par.fullUrl ?? '' parUrl.value = par.fullUrl ?? ''
parPanel.value?.refresh() parPanel.value?.refresh()
@@ -848,7 +897,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
:title="parIsBucket ? '分享桶' : '分享对象'" :title="parIsBucket ? '分享桶' : '分享对象'"
:width="460" :width="460"
:submitting="parBusy" :submitting="parBusy"
:submit-disabled="!!parUrl || !parForm.expiresHours" :submit-disabled="!!parUrl || !parExpiresValid"
submit-text="签发" submit-text="签发"
@update:show="showPar = $event" @update:show="showPar = $event"
@submit="doCreatePar" @submit="doCreatePar"
@@ -862,12 +911,12 @@ const columns = computed<DataTableColumns<Row>>(() => [
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio> <NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
</NRadioGroup> </NRadioGroup>
</FormField> </FormField>
<FormField label="有效期" hint="OCI 上限 30 (720 小时)"> <FormField label="有效期" hint="最长 100 876000 小时">
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber <AppInputNumber
v-model:value="parForm.expiresHours" v-model:value="parForm.expiresHours"
:min="1" :min="1"
:max="720" :max="PAR_MAX_EXPIRES_HOURS"
:precision="0" :precision="0"
size="small" size="small"
class="!w-24" class="!w-24"
@@ -115,8 +115,10 @@ watch(
return return
} }
reset() reset()
const seq = ++contentSeq
void detail.run().then(() => { void detail.run().then(() => {
if (!blockedReason.value) void loadContent() // detail 是共享 useAsync:换对象后旧 then 链仍会执行,按代次拦下
if (seq === contentSeq && !blockedReason.value) void loadContent()
}) })
}, },
) )
@@ -145,19 +147,25 @@ function revokeImage() {
imageUrl.value = '' imageUrl.value = ''
} }
// 内容加载代次:换对象/关闭即自增,旧对象的迟到响应按代次丢弃——
// 否则 A 的正文与 ETag 会顶着 B 的标题展示,编辑保存还会拿错基线
let contentSeq = 0
async function loadContent() { async function loadContent() {
if (!props.cfgId || loading.value) return if (!props.cfgId || loading.value) return
const seq = ++contentSeq
loading.value = true loading.value = true
loadError.value = '' loadError.value = ''
try { try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region) const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
if (seq !== contentSeq) return
contentEtag.value = c.etag contentEtag.value = c.etag
contentType.value = c.contentType contentType.value = c.contentType
await applyContent(c.blob) await applyContent(c.blob)
} catch (e) { } catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败' if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally { } finally {
loading.value = false if (seq === contentSeq) loading.value = false
} }
} }
+2 -1
View File
@@ -35,7 +35,8 @@ const pars = useAsync<ParPage>(async () => {
const items = computed(() => pars.data.value?.items ?? []) const items = computed(() => pars.data.value?.items ?? [])
watch( watch(
[() => props.cfgId, () => props.bucket], // region 必须在依赖里:切区域后旧列表配新区域,撤销会删错区域的同名 PAR
[() => props.cfgId, () => props.region, () => props.bucket],
() => { () => {
cursorStack.value = [] cursorStack.value = []
cursor.value = '' cursor.value = ''
@@ -322,23 +322,8 @@ const secretConfigured = computed(() => {
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
}) })
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */ /** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest { function providerFormPatch(): 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<UpdateOAuthRequest> {
const name = pForm.displayName.trim() const name = pForm.displayName.trim()
const id = pForm.clientId.trim() const id = pForm.clientId.trim()
if (pForm.provider === 'oidc') if (pForm.provider === 'oidc')
@@ -358,7 +343,7 @@ function providerFormPatch(): Partial<UpdateOAuthRequest> {
async function saveProvider() { async function saveProvider() {
savingOauth.value = true savingOauth.value = true
try { try {
await updateOAuthSettings(oauthPayloadWith(providerFormPatch())) await updateOAuthSettings(providerFormPatch())
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`) message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
showProviderModal.value = false showProviderModal.value = false
void oauthCfg.run({ silent: true }) void oauthCfg.run({ silent: true })
@@ -374,7 +359,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
savingOauth.value = true savingOauth.value = true
try { try {
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled } const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
await updateOAuthSettings(oauthPayloadWith(patch)) await updateOAuthSettings(patch)
message.success(`${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`) message.success(`${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
void oauthCfg.run({ silent: true }) void oauthCfg.run({ silent: true })
} catch (e) { } catch (e) {
@@ -393,7 +378,7 @@ async function deleteProvider(p: ProviderType) {
p === 'oidc' p === 'oidc'
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false } ? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false } : { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
await updateOAuthSettings(oauthPayloadWith(patch)) await updateOAuthSettings(patch)
message.success(`登录方式「${name}」已删除`) message.success(`登录方式「${name}」已删除`)
void oauthCfg.run({ silent: true }) void oauthCfg.run({ silent: true })
} catch (e) { } catch (e) {
@@ -570,7 +555,7 @@ async function deleteProvider(p: ProviderType) {
</td> </td>
<td class="border-b border-line-soft px-3 py-2.5"> <td class="border-b border-line-soft px-3 py-2.5">
<span <span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2" class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
> >
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" /> <span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
{{ row.disabled ? '已禁用' : '启用中' }} {{ row.disabled ? '已禁用' : '启用中' }}
+3 -1
View File
@@ -70,7 +70,9 @@ watch(
if (t.type === 'snatch') { if (t.type === 'snatch') {
const inst = (p.instance ?? {}) as Record<string, unknown> const inst = (p.instance ?? {}) as Record<string, unknown>
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null 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.displayName = String(inst.displayName ?? '')
form.snatchRegion = typeof inst.region === 'string' ? inst.region : '' form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : '' form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
+227
View File
@@ -0,0 +1,227 @@
<script setup lang="ts">
import { NButton, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { activateApiKey, addUserApiKey, deleteUserApiKey, listUserApiKeys } from '@/api/tenant'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { fmtTime } from '@/composables/useFormat'
import { useMobileModal } from '@/composables/useMobileModal'
import { useToast } from '@/composables/useToast'
import type { CreatedApiKey, IamUser, UserApiKey } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; user: IamUser | null }>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const toast = useToast()
const { modalStyle, modalClass } = useMobileModal(760)
const keys = ref<UserApiKey[]>([])
const loading = ref(false)
const busy = ref(false)
const activating = ref(false)
const activated = ref(false)
const selectedFp = ref('')
/** 本次弹窗内刚创建的 key;选中它时右栏进入结果态(私钥仅此一次可下载) */
const created = ref<CreatedApiKey | null>(null)
const selected = computed(() => keys.value.find((k) => k.fingerprint === selectedFp.value) ?? null)
const isCreatedView = computed(
() => !!created.value && selectedFp.value === created.value.fingerprint,
)
watch(
() => props.show,
(show) => {
if (show) {
created.value = null
activated.value = false
selectedFp.value = ''
void reload()
}
},
)
async function reload() {
if (!props.user) return
loading.value = true
try {
keys.value = (await listUserApiKeys(props.cfgId, props.user.id)).items
if (!keys.value.some((k) => k.fingerprint === selectedFp.value))
selectedFp.value = keys.value[0]?.fingerprint ?? ''
} catch (e) {
toast.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
async function doAdd() {
if (!props.user || busy.value || keys.value.length >= 3) return
busy.value = true
try {
const res = await addUserApiKey(props.cfgId, props.user.id)
created.value = res
activated.value = false
await reload()
selectedFp.value = res.fingerprint
} catch (e) {
toast.error(e instanceof Error ? e.message : '创建失败')
} finally {
busy.value = false
}
}
async function doDelete(fp: string) {
if (!props.user) return
try {
await deleteUserApiKey(props.cfgId, props.user.id, fp)
toast.success('已删除')
if (created.value?.fingerprint === fp) created.value = null
await reload()
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
}
}
/** 把刚创建的 key 设为面板签名凭据;私钥仅存在于本次弹窗内存中 */
async function doActivate() {
if (!created.value || activating.value) return
activating.value = true
try {
await activateApiKey(props.cfgId, created.value.fingerprint, created.value.privateKey)
activated.value = true
toast.success('面板签名凭据已切换到该 key')
await reload()
} catch (e) {
toast.error(e instanceof Error ? e.message : '设置失败')
} finally {
activating.value = false
}
}
function download() {
if (!created.value) return
const blob = new Blob([created.value.privateKey], { type: 'application/x-pem-file' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'oci_api_key.pem'
a.click()
URL.revokeObjectURL(a.href)
}
async function copyIni(text: string) {
await navigator.clipboard.writeText(text)
toast.success('已复制配置')
}
</script>
<template>
<NModal
:show="show"
preset="card"
:style="modalStyle"
:class="modalClass"
@update:show="emit('update:show', $event)"
>
<template #header>
<div>
<div class="text-[15px] leading-tight font-semibold">API Keys</div>
<div class="mt-1 text-xs font-normal text-ink-3">
{{ user?.name }}{{ user?.isCurrentUser ? ' · 当前签名用户' : '' }}
</div>
</div>
</template>
<div class="flex max-md:flex-col md:min-h-[300px]">
<!-- 左栏:key 列表 -->
<div
class="flex w-[218px] flex-none flex-col gap-0.5 border-r border-line-soft pr-2.5 max-md:w-full max-md:border-r-0 max-md:border-b max-md:pr-0 max-md:pb-2.5"
>
<div class="px-2.5 pb-1.5 text-[11px] text-ink-3">{{ keys.length }} / 3 </div>
<div v-if="loading && !keys.length" class="px-2.5 py-6 text-center text-xs text-ink-3">加载中</div>
<button
v-for="k in keys"
:key="k.fingerprint"
type="button"
class="cursor-pointer rounded-[7px] px-2.5 py-[7px] text-left hover:bg-row-hover"
:class="selectedFp === k.fingerprint ? 'bg-row-hover' : ''"
@click="selectedFp = k.fingerprint"
>
<div class="mono truncate text-xs">{{ k.fingerprint }}</div>
<div class="mt-0.5 text-[11px] text-ink-3">
{{ created?.fingerprint === k.fingerprint ? '刚刚创建' : fmtTime(k.timeCreated) }}
</div>
</button>
<button
type="button"
class="mt-auto cursor-pointer rounded-[7px] border border-dashed border-line px-2.5 py-[7px] text-xs text-ink-3 enabled:hover:border-accent enabled:hover:text-accent disabled:opacity-60 max-md:mt-2"
:disabled="busy || keys.length >= 3"
@click="doAdd"
>
{{ busy ? '创建中…' : '+ 添加 Key' }}
</button>
</div>
<!-- 右栏:详情 / 创建结果 -->
<div class="min-w-0 flex-1 pt-0.5 pl-4 max-md:pt-3 max-md:pl-0">
<template v-if="isCreatedView && created">
<div class="rounded-lg bg-warn/14 px-3 py-2 text-xs leading-relaxed text-warn">
私钥仅可下载这一次,离开本页后无法再次获取
</div>
<div class="mt-3 flex items-center gap-2 rounded-lg border border-line-soft bg-white px-3 py-2.5">
<span class="mono text-xs">oci_api_key.pem</span>
<span class="text-[11px] text-ink-3">RSA-2048</span>
<span class="min-w-0 flex-1"></span>
<NButton size="tiny" type="primary" @click="download">下载私钥</NButton>
</div>
<div class="mt-3 overflow-hidden rounded-lg border border-line-soft bg-white">
<div class="flex items-center px-3 pt-2">
<span class="text-[11px] text-ink-3">~/.oci/config</span>
<span class="min-w-0 flex-1"></span>
<NButton size="tiny" quaternary @click="copyIni(created.configIni)">复制</NButton>
</div>
<pre class="mono overflow-x-auto px-3 pt-1 pb-2.5 text-xs leading-relaxed">{{ created.configIni }}</pre>
</div>
<div v-if="user?.isCurrentUser" class="mt-3 flex">
<NButton size="tiny" secondary :loading="activating" :disabled="activated" @click="doActivate">
{{ activated ? '已设为面板签名 key' : '设为面板签名 key' }}
</NButton>
</div>
</template>
<template v-else-if="selected">
<div class="mono text-[13px] leading-relaxed break-all">{{ selected.fingerprint }}</div>
<div class="mt-1 text-[11.5px] text-ink-3">
创建于 {{ fmtTime(selected.timeCreated) }} · RSA-2048{{ selected.isCurrent ? ' · 面板签名使用中' : '' }}
</div>
<div class="mt-3 overflow-hidden rounded-lg border border-line-soft bg-white">
<div class="flex items-center px-3 pt-2">
<span class="text-[11px] text-ink-3">~/.oci/config · key_file </span>
<span class="min-w-0 flex-1"></span>
<NButton size="tiny" quaternary @click="copyIni(selected.configIni)">复制</NButton>
</div>
<pre class="mono overflow-x-auto px-3 pt-1 pb-2.5 text-xs leading-relaxed">{{ selected.configIni }}</pre>
</div>
<div class="mt-3 flex">
<span class="min-w-0 flex-1"></span>
<ConfirmPop
v-if="!selected.isCurrent"
title="删除该 API Key?"
content="删除后使用它签名的调用立即失效。"
confirm-text="删除"
:on-confirm="() => doDelete(selectedFp)"
>
<template #trigger>
<NButton size="tiny" secondary type="error">删除此 Key</NButton>
</template>
</ConfirmPop>
</div>
</template>
<div v-else-if="!loading" class="py-12 text-center text-xs text-ink-3">
该用户暂无 API Key,点左侧添加 Key创建
</div>
</div>
</div>
</NModal>
</template>
+7 -3
View File
@@ -182,7 +182,11 @@ function rowProps(row: AuditEvent) {
} }
} }
// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情
let rawSeq = 0
async function loadRaw(row: AuditEvent) { async function loadRaw(row: AuditEvent) {
const seq = ++rawSeq
detailRaw.value = null detailRaw.value = null
rawGone.value = false rawGone.value = false
if (!row.eventId || !row.eventTime) { if (!row.eventId || !row.eventTime) {
@@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
eventId: row.eventId, eventId: row.eventId,
eventTime: row.eventTime, eventTime: row.eventTime,
}) })
detailRaw.value = raw if (seq === rawSeq) detailRaw.value = raw
} catch { } catch {
rawGone.value = true if (seq === rawSeq) rawGone.value = true
} finally { } finally {
rawLoading.value = false if (seq === rawSeq) rawLoading.value = false
} }
} }
+157 -21
View File
@@ -1,12 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui' import { NButton, NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant' import {
createIdp,
deleteIdpIcon,
getIdentitySetting,
uploadIdpIcon,
type CreateIdpBody,
} from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue' import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue' import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue' import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import type { IdpIconUpload } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>() const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>() const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
@@ -15,6 +22,7 @@ const message = useToast()
const submitting = ref(false) const submitting = ref(false)
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱 // 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
const primaryEmailRequired = ref(false) const primaryEmailRequired = ref(false)
const identitySettingReady = ref(false)
const nameIdFormatOptions = [ const nameIdFormatOptions = [
{ label: '无', value: 'saml-none' }, { label: '无', value: 'saml-none' },
@@ -51,22 +59,66 @@ const blank = {
} }
const form = reactive({ ...blank }) const form = reactive({ ...blank })
// ---- 图标上传:传到身份域公共图片存储(/storage/v1/Images),回填公网 URL ----
const iconFileEl = ref<HTMLInputElement | null>(null)
const iconUploading = ref(false)
// 最近上传且尚未被创建采用的图标;替换、放弃表单或提交未引用时尽力删除。
// 删除失败只会在自己租户里留下一张无入口的小图,静默接受,不打扰用户。
let pendingIcon: (IdpIconUpload & { cfgId: number; domainId?: string }) | null = null
let identitySettingSeq = 0
// 图片加载失败时回退占位,避免预览框里出现浏览器裂图
const iconError = ref(false)
watch( watch(
() => props.show, () => form.iconUrl,
(v) => { () => (iconError.value = false),
if (!v) return )
// 上传请求序号:重开弹窗或重新选文件即自增,旧请求的响应按序号丢弃
let iconUploadSeq = 0
function invalidateIconUpload() {
iconUploadSeq++
iconUploading.value = false
}
function cleanupPendingIcon() {
const icon = pendingIcon
pendingIcon = null
if (icon) void deleteIdpIcon(icon.cfgId, icon.fileName, icon.domainId).catch(() => {})
}
function abandonForm() {
identitySettingSeq++
identitySettingReady.value = false
invalidateIconUpload()
cleanupPendingIcon()
}
function resetForm() {
abandonForm()
primaryEmailRequired.value = false
Object.assign(form, blank) Object.assign(form, blank)
void loadIdentitySetting() void loadIdentitySetting()
}, }
watch(
() => [props.show, props.cfgId, props.domainId] as const,
([show]) => (show ? resetForm() : abandonForm()),
{ immediate: true },
) )
// show/cfgId/domainId 任一变化都会经 abandonForm 递增序号,故过期只需比对序号
async function loadIdentitySetting() { async function loadIdentitySetting() {
const seq = ++identitySettingSeq
identitySettingReady.value = false
try { try {
const s = await getIdentitySetting(props.cfgId) const s = await getIdentitySetting(props.cfgId, props.domainId)
if (seq !== identitySettingSeq) return
primaryEmailRequired.value = s.primaryEmailRequired primaryEmailRequired.value = s.primaryEmailRequired
if (s.primaryEmailRequired) form.jitMapEmail = true if (s.primaryEmailRequired) form.jitMapEmail = true
} catch { } catch {
primaryEmailRequired.value = false if (seq === identitySettingSeq) primaryEmailRequired.value = false
} finally {
if (seq === identitySettingSeq) identitySettingReady.value = true
} }
} }
@@ -76,11 +128,60 @@ const iconUrlValid = computed(() => {
return !v || /^https?:\/\//.test(v) return !v || /^https?:\/\//.test(v)
}) })
async function onIconPick(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || submitting.value) return
if (file.size > 1 << 20) {
message.error('图标不能超过 1MB')
return
}
const { cfgId, domainId } = props
const seq = ++iconUploadSeq
iconUploading.value = true
try {
const result = await uploadIdpIcon(cfgId, file, domainId)
if (seq !== iconUploadSeq) {
void deleteIdpIcon(cfgId, result.fileName, domainId).catch(() => {})
return
}
cleanupPendingIcon()
pendingIcon = { ...result, cfgId, domainId }
form.iconUrl = result.url
message.success('图标已上传到身份域公共存储')
} catch (err) {
if (seq === iconUploadSeq) message.error(err instanceof Error ? err.message : '上传失败')
} finally {
if (seq === iconUploadSeq) iconUploading.value = false
}
}
function onIconUrlInput(value: string) {
if (submitting.value || value === form.iconUrl) return
invalidateIconUpload()
form.iconUrl = value
}
function closeForm() {
abandonForm()
emit('update:show', false)
}
function onModalShowChange(show: boolean) {
if (!show && submitting.value) return
if (show) emit('update:show', true)
else closeForm()
}
const canSubmit = computed( const canSubmit = computed(
() => () =>
!!form.name.trim() && !!form.name.trim() &&
form.metadata.includes('EntityDescriptor') && form.metadata.includes('EntityDescriptor') &&
iconUrlValid.value && iconUrlValid.value &&
!iconUploading.value &&
identitySettingReady.value &&
!submitting.value &&
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()), (form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
) )
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
} }
async function submit() { async function submit() {
if (submitting.value || !canSubmit.value) return
const body = buildBody()
submitting.value = true submitting.value = true
try { try {
const created = await createIdp(props.cfgId, buildBody(), props.domainId) const created = await createIdp(props.cfgId, body, props.domainId)
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`) // 创建请求引用了刚上传的图标即视为已采用,交给 IdP 生命周期,不再清理
emit('update:show', false) if (pendingIcon && pendingIcon.url === body.iconUrl) pendingIcon = null
const warning = created.setupWarning
if (warning)
message.warning(`IdP「${created.name}」的创建结果需确认`, `${warning.message}\n关联 ID${warning.requestId}`)
else message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
closeForm()
emit('created') emit('created')
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '创建失败') message.error(e instanceof Error ? e.message : '创建失败')
@@ -124,12 +232,17 @@ async function submit() {
:submitting="submitting" :submitting="submitting"
:submit-disabled="!canSubmit" :submit-disabled="!canSubmit"
submit-text="创建禁用态" submit-text="创建禁用态"
@update:show="emit('update:show', $event)" @update:show="onModalShowChange"
@submit="submit" @submit="submit"
> >
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名称" required> <FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" /> <NInput v-model:value="form.name" placeholder="my-idp" />
</FormField> </FormField>
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
</div>
<FormField <FormField
label="IdP SAML metadata XML 文件" label="IdP SAML metadata XML 文件"
required required
@@ -153,26 +266,49 @@ async function submit() {
@load="(text) => (form.metadata = text)" @load="(text) => (form.metadata = text)"
/> />
</FormField> </FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
<FormField <FormField
label="Logo(登录页图标)" label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传" hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'" :error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div
class="flex h-[34px] w-[34px] flex-none items-center justify-center overflow-hidden rounded border border-line-soft bg-wash"
>
<img <img
v-if="form.iconUrl && iconUrlValid" v-if="form.iconUrl && iconUrlValid && !iconError"
:src="form.iconUrl" :src="form.iconUrl"
alt="" alt=""
class="h-7 w-7 flex-none rounded border border-line-soft object-contain" class="h-full w-full object-contain"
@error="iconError = true"
/>
<span v-else class="text-[10px] text-ink-3 select-none">Logo</span>
</div>
<NInput
:value="form.iconUrl"
:disabled="submitting"
placeholder="https://…/logo.png,或点右侧上传"
class="min-w-0 flex-1"
@update:value="onIconUrlInput"
/>
<NButton
class="flex-none"
:loading="iconUploading"
:disabled="submitting"
@click="iconFileEl?.click()"
>
上传
</NButton>
<input
ref="iconFileEl"
type="file"
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
:disabled="submitting"
class="hidden"
@change="onIconPick"
/> />
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
</div> </div>
</FormField> </FormField>
</div>
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold"> <div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
映射用户身份 映射用户身份
+26 -16
View File
@@ -2,9 +2,10 @@
import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui' import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue' import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant' import { clearUserMfa, deleteUser, listUsers, resetUserPassword } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue' import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue' import StatusBadge from '@/components/StatusBadge.vue'
import ApiKeysModal from '@/components/tenant/ApiKeysModal.vue'
import UserFormModal from '@/components/tenant/UserFormModal.vue' import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat' import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync' import { useAsync } from '@/composables/useAsync'
@@ -19,6 +20,8 @@ const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId, props.domainId)) const users = useAsync(() => listUsers(props.cfgId, props.domainId))
const showForm = ref(false) const showForm = ref(false)
const editingUser = ref<IamUser | null>(null) const editingUser = ref<IamUser | null>(null)
const showKeys = ref(false)
const keysUser = ref<IamUser | null>(null)
function openCreate() { function openCreate() {
editingUser.value = null editingUser.value = null
@@ -30,6 +33,11 @@ function openEdit(row: IamUser) {
showForm.value = true showForm.value = true
} }
function openKeys(row: IamUser) {
keysUser.value = row
showKeys.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) { async function act(fn: () => Promise<unknown>, ok: string) {
try { try {
await fn() await fn()
@@ -112,11 +120,23 @@ const columns: DataTableColumns<IamUser> = [
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 290, width: 300,
render: (row) => render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [ h('div', { class: 'flex items-center gap-1' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }), h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }), h(
ConfirmPop,
{
title: '重置密码?',
content: `将为 ${row.name} 生成一次性新密码。`,
kind: 'warn',
confirmText: '重置',
onConfirm: () => resetPwd(row),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '重置密码' }),
},
),
h( h(
ConfirmPop, ConfirmPop,
{ {
@@ -131,19 +151,7 @@ const columns: DataTableColumns<IamUser> = [
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }), trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
}, },
), ),
h( h(NButton, { size: 'tiny', quaternary: true, onClick: () => openKeys(row) }, { default: () => 'API Keys' }),
ConfirmPop,
{
title: '删除全部 API Key',
content: '跳过当前配置使用中的指纹。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
},
),
row.isCurrentUser row.isCurrentUser
? null ? null
: h( : h(
@@ -180,5 +188,7 @@ const columns: DataTableColumns<IamUser> = [
:user="editingUser" :user="editingUser"
@created="users.run()" @created="users.run()"
/> />
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" />
</div> </div>
</template> </template>
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { useAsync } from './useAsync'
/** 手动控制解析时机的 fetcher,复现请求乱序返回 */
function deferredFetcher<T>() {
const pending: Array<{ resolve: (v: T) => void; reject: (e: unknown) => void }> = []
const fetcher = () =>
new Promise<T>((resolve, reject) => {
pending.push({ resolve, reject })
})
return { fetcher, pending }
}
describe('useAsync 并发与乱序', () => {
it('后发请求先返回时,先发请求的迟到结果被丢弃', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
const p2 = st.run()
pending[1].resolve('新')
await p2
pending[0].resolve('旧')
await p1
expect(st.data.value).toBe('新')
expect(st.loading.value).toBe(false)
})
it('迟到的失败不覆盖最新成功结果', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
const p2 = st.run()
pending[1].resolve('新')
await p2
pending[0].reject(new Error('旧请求失败'))
await p1
expect(st.data.value).toBe('新')
expect(st.error.value).toBe('')
})
it('silent 刷新不置 loading,普通刷新置 loading', async () => {
const { fetcher, pending } = deferredFetcher<number>()
const st = useAsync(fetcher, false)
const p1 = st.run({ silent: true })
expect(st.loading.value).toBe(false)
pending[0].resolve(1)
await p1
const p2 = st.run()
expect(st.loading.value).toBe(true)
pending[1].resolve(2)
await p2
expect(st.loading.value).toBe(false)
expect(st.data.value).toBe(2)
})
it('失败时记录 error 并清 loading', async () => {
const { fetcher, pending } = deferredFetcher<string>()
const st = useAsync(fetcher, false)
const p1 = st.run()
pending[0].reject(new Error('boom'))
await p1
expect(st.error.value).toBe('boom')
expect(st.loading.value).toBe(false)
expect(st.data.value).toBeNull()
})
})
+2 -2
View File
@@ -100,8 +100,8 @@ const router = createRouter({
router.beforeEach((to) => { router.beforeEach((to) => {
const auth = useAuthStore() const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } } if (!to.meta.public && !auth.isAuthed()) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed) return { name: 'overview' } if (to.name === 'login' && auth.isAuthed()) return { name: 'overview' }
return true return true
}) })
+49
View File
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from './auth'
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
describe('isAuthed 实时过期判定', () => {
const cases = [
{ name: '无 token 未登录', token: '', expiresAt: '', want: false },
{ name: '有 token 无过期时间视为已登录', token: 't', expiresAt: '', want: true },
{
name: '未过期',
token: 't',
expiresAt: new Date(Date.now() + 60_000).toISOString(),
want: true,
},
{
name: '已过期(computed 缓存会误判的场景)',
token: 't',
expiresAt: new Date(Date.now() - 1_000).toISOString(),
want: false,
},
]
for (const c of cases) {
it(c.name, () => {
const auth = useAuthStore()
if (c.token) auth.setSession(c.token, c.expiresAt)
expect(auth.isAuthed()).toBe(c.want)
})
}
})
it('logout 清空会话与本地存储', () => {
const auth = useAuthStore()
auth.setSession('t', new Date(Date.now() + 60_000).toISOString())
auth.logout()
expect(auth.token).toBe('')
expect(auth.isAuthed()).toBe(false)
expect(localStorage.getItem('oci-portal.token')).toBeNull()
})
it('setSession 落地本地存储,新 store 实例可恢复', () => {
useAuthStore().setSession('t2', '')
setActivePinia(createPinia())
expect(useAuthStore().token).toBe('t2')
})
+5 -3
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { ref } from 'vue'
const TOKEN_KEY = 'oci-portal.token' const TOKEN_KEY = 'oci-portal.token'
const EXPIRES_KEY = 'oci-portal.expiresAt' const EXPIRES_KEY = 'oci-portal.expiresAt'
@@ -8,11 +8,13 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '') const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '') const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
const isAuthed = computed(() => { // 普通函数而非 computed:computed 只随 token/expiresAt 失效,时间流逝不会
// 触发重算,令牌自然过期后路由守卫会长期误判仍已登录
function isAuthed(): boolean {
if (!token.value) return false if (!token.value) return false
if (!expiresAt.value) return true if (!expiresAt.value) return true
return new Date(expiresAt.value).getTime() > Date.now() return new Date(expiresAt.value).getTime() > Date.now()
}) }
function setSession(newToken: string, newExpiresAt: string) { function setSession(newToken: string, newExpiresAt: string) {
token.value = newToken token.value = newToken
+46 -8
View File
@@ -66,17 +66,17 @@ export interface OAuthSettings {
githubDisabled: boolean githubDisabled: boolean
} }
/** 保存 provider 配置;secret 缺省沿用、空串清除 */ /** provider 配置字段补丁:缺省字段沿用现值;secret 空串清除、缺省沿用 */
export interface UpdateOAuthRequest { export interface UpdateOAuthRequest {
oidcIssuer: string oidcIssuer?: string
oidcClientId: string oidcClientId?: string
oidcClientSecret?: string oidcClientSecret?: string
oidcDisplayName: string oidcDisplayName?: string
oidcDisabled: boolean oidcDisabled?: boolean
githubClientId: string githubClientId?: string
githubClientSecret?: string githubClientSecret?: string
githubDisplayName: string githubDisplayName?: string
githubDisabled: boolean githubDisabled?: boolean
} }
/** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */ /** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */
@@ -639,6 +639,21 @@ export interface IamUser {
lastLoginTime: string | null lastLoginTime: string | null
} }
// 用户 API 签名 key;isCurrent 表示当前配置正用它签名,configIni 为该 key 的 CLI 配置模板
export interface UserApiKey {
fingerprint: string
timeCreated: string | null
isCurrent: boolean
configIni: string
}
// 新建/轮换 key 的一次性返回;私钥仅此一次,关闭即不可再取
export interface CreatedApiKey {
fingerprint: string
privateKey: string
configIni: string
}
// 编辑表单用的域档案与管理员状态;inDomain 为 false 表示经典 IAM 用户 // 编辑表单用的域档案与管理员状态;inDomain 为 false 表示经典 IAM 用户
export interface IamUserDetail { export interface IamUserDetail {
inDomain: boolean inDomain: boolean
@@ -699,6 +714,13 @@ export interface IdentitySetting {
} }
// ---- Federation ---- // ---- Federation ----
export interface IdpSetupWarning {
code: 'JIT_SETUP_INCOMPLETE'
message: string
resourceCreated: true
requestId: string
}
export interface IdentityProvider { export interface IdentityProvider {
id: string id: string
name: string name: string
@@ -707,6 +729,14 @@ export interface IdentityProvider {
partnerProviderId: string partnerProviderId: string
jitEnabled: boolean jitEnabled: boolean
timeCreated: string timeCreated: string
/** 资源曾创建且回滚状态不确定;创建方必须按资源可能存在处理 */
setupWarning?: IdpSetupWarning
}
/** IdP 图标上传结果;fileName 为身份域存储内标识,留作后续清理 */
export interface IdpIconUpload {
url: string
fileName: string
} }
export interface SignOnRule { export interface SignOnRule {
@@ -756,6 +786,14 @@ export interface OverviewCost {
currency: string currency: string
total: number total: number
days: OverviewCostDay[] days: OverviewCostDay[]
/** 按币种拆分的序列(合计降序);顶层 currency/total/days 恒为首个主币种,跨币种不相加 */
series: OverviewCostSeries[]
}
export interface OverviewCostSeries {
currency: string
total: number
days: OverviewCostDay[]
} }
export interface OverviewTasks { export interface OverviewTasks {
+11
View File
@@ -50,6 +50,14 @@ const currencySymbol = computed(() => {
const currency = ov.value?.cost.currency const currency = ov.value?.cost.currency
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$' return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
}) })
/** 主币种之外的其余币种合计;跨币种金额不可相加,只并列展示 */
const otherCurrencyTotals = computed(() =>
(ov.value?.cost.series ?? [])
.slice(1)
.map((s) => `${s.currency} ${s.total.toFixed(2)}`)
.join('、'),
)
</script> </script>
<template> <template>
@@ -126,6 +134,9 @@ const currencySymbol = computed(() => {
<div class="text-[12.5px] text-ink-3"> <div class="text-[12.5px] text-ink-3">
{{ costDaily.labels[0] }} {{ costDaily.labels.at(-1) }} 合计 {{ costDaily.labels[0] }} {{ costDaily.labels.at(-1) }} 合计
</div> </div>
<div v-if="otherCurrencyTotals" class="text-[12.5px] text-ink-3">
另有 {{ otherCurrencyTotals }}跨币种不相加
</div>
</div> </div>
<div class="px-3 pb-2"> <div class="px-3 pb-2">
<CostChart :labels="costDaily.labels" :values="costDaily.values" /> <CostChart :labels="costDaily.labels" :values="costDaily.values" />
+24 -12
View File
@@ -42,12 +42,15 @@ const auth = useAuthStore()
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号) // 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
const tab = ref('notify') const tab = ref('notify')
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab; // OAuth 绑定回跳:绑定使令牌版本递增,fragment 携带的新 token 必须在本组件
// 绑定使令牌版本递增,fragment 携带的新 token 须先落地,否则后续请求 401 // 任何请求发起前(setup 顶部)落地——setup 阶段的 useAsync 会立即用旧 token
// 发请求,迟到的 401 会误清刚写入的新会话
consumeBindToken()
// 成功/失败提示后清理 query,并停在安全 tab
onMounted(() => { onMounted(() => {
const { oauth, oauthError } = route.query const { oauth, oauthError } = route.query
if (oauth !== 'bound' && !oauthError) return if (oauth !== 'bound' && !oauthError) return
consumeBindToken()
tab.value = 'security' tab.value = 'security'
if (oauth === 'bound') message.success('外部身份绑定成功') if (oauth === 'bound') message.success('外部身份绑定成功')
if (typeof oauthError === 'string' && oauthError) message.error(oauthError) if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
@@ -244,7 +247,7 @@ function pickHeader(v: string) {
customHeaderMode.value = false customHeaderMode.value = false
if (securityForm.value.realIpHeader === v) return if (securityForm.value.realIpHeader === v) return
securityForm.value.realIpHeader = v securityForm.value.realIpHeader = v
void saveSecurity() void saveSecurity({ realIpHeader: v })
} }
function pickCustomHeader() { function pickCustomHeader() {
@@ -265,18 +268,27 @@ watch(
}, },
) )
/** 控件变更即全量 PUT(与 AI 设置一致);失败重拉服务端值回滚 UI */ /** 控件变更即发字段补丁,服务端只落库出现字段(并发编辑互不回滚);
async function saveSecurity() { * 失败重拉服务端值回滚 UI。在途时把补丁并入尾随一轮,完成后合并补发。 */
let pendingSecurityPatch: Partial<SecuritySetting> | null = null
async function saveSecurity(patch: Partial<SecuritySetting>) {
if (savingSecurity.value) {
pendingSecurityPatch = { ...pendingSecurityPatch, ...patch }
return
}
savingSecurity.value = true savingSecurity.value = true
try { try {
await updateSecuritySetting({ ...securityForm.value }) await updateSecuritySetting(patch)
message.success('已保存,立即生效') message.success('已保存,立即生效')
void security.run({ silent: true }) if (!pendingSecurityPatch) void security.run({ silent: true })
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败') message.error(e instanceof Error ? e.message : '保存失败')
void security.run({ silent: true }) if (!pendingSecurityPatch) void security.run({ silent: true })
} finally { } finally {
savingSecurity.value = false savingSecurity.value = false
const next = pendingSecurityPatch
pendingSecurityPatch = null
if (next) void saveSecurity(next)
} }
} }
@@ -287,7 +299,7 @@ function commitSecurityNum(
) { ) {
if (v == null || v === securityForm.value[key]) return if (v == null || v === securityForm.value[key]) return
securityForm.value[key] = v securityForm.value[key] = v
void saveSecurity() void saveSecurity({ [key]: v })
} }
async function saveTaskSetting() { async function saveTaskSetting() {
@@ -676,7 +688,7 @@ async function sendTest() {
v-model:value="securityForm.realIpHeader" v-model:value="securityForm.realIpHeader"
class="mono mt-2 !w-70" class="mono mt-2 !w-70"
placeholder="自定义头名,如 X-Client-IP" placeholder="自定义头名,如 X-Client-IP"
@change="() => void saveSecurity()" @change="() => void saveSecurity({ realIpHeader: securityForm.realIpHeader })"
/> />
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3"> <div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
头名仅限字母数字与连字符 头名仅限字母数字与连字符
@@ -689,7 +701,7 @@ async function sendTest() {
<NInput <NInput
v-model:value="securityForm.appUrl" v-model:value="securityForm.appUrl"
placeholder="https://demo.example.com" placeholder="https://demo.example.com"
@change="() => void saveSecurity()" @change="() => void saveSecurity({ appUrl: securityForm.appUrl })"
/> />
</FormField> </FormField>
</div> </div>
+13 -3
View File
@@ -20,9 +20,19 @@ const tasks = useAsync(listTasks)
const showCreate = ref(false) const showCreate = ref(false)
const editingTask = ref<Task | null>(null) const editingTask = ref<Task | null>(null)
// 每 5 秒静默刷新不闪 loading // 每 5 秒静默刷新(不闪 loading);上一轮完成后再排下一轮,
const refreshTimer = setInterval(() => void tasks.run({ silent: true }), 5000) // 接口变慢时不堆叠请求,也避免响应总被下一轮标记过期而饿死
onUnmounted(() => clearInterval(refreshTimer)) let refreshTimer = 0
let refreshStopped = false
async function refreshLoop() {
await tasks.run({ silent: true })
if (!refreshStopped) refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
}
refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
onUnmounted(() => {
refreshStopped = true
clearTimeout(refreshTimer)
})
const cfgAliasById = computed( const cfgAliasById = computed(
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])), () => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),
+7 -2
View File
@@ -1,12 +1,17 @@
{ {
"extends": "@tsconfig/node22/tsconfig.json", "extends": "@tsconfig/node22/tsconfig.json",
"include": ["vite.config.ts"], "include": [
"vite.config.ts",
"vitest.config.ts"
],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"noEmit": true, "noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"types": ["node"] "types": [
"node"
]
} }
} }
+15
View File
@@ -0,0 +1,15 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
// 测试独立配置,不复用 vite.config:其 PWA/手动分包插件与单测无关且拖慢启动
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
})