6 Commits
Author SHA1 Message Date
wangdefa ec8a50c4c0 发布 v0.8.3
CI / test (push) Successful in 46s
Release / release (push) Successful in 45s
2026-07-30 12:41:55 +08:00
wangdefa d675b950ee 安全页重构:免密登录与活跃会话;按钮规范统一
CI / test (push) Successful in 53s
2026-07-30 12:23:16 +08:00
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
75 changed files with 3585 additions and 1299 deletions
+2 -1
View File
@@ -1,6 +1,5 @@
name: CI
# 仅分支推送触发:tag 推送交给 release.yml;solo 开发不走 PR,去掉 pull_request 避免同一提交双跑
on:
push:
branches: ["**"]
@@ -20,5 +19,7 @@ jobs:
run: npx eslint .
- name: 类型检查
run: npm run typecheck
- name: 单元测试
run: npm run test
- name: 构建
run: npm run build
+2
View File
@@ -19,5 +19,7 @@ jobs:
run: npx eslint .
- name: 类型检查
run: npm run typecheck
- name: 单元测试
run: npm run test
- name: 构建
run: npm run build
+1 -1
View File
@@ -1 +1 @@
0.6.6
0.6.10
+83
View File
@@ -9,3 +9,86 @@
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。
- 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。
- 表单先上传临时远端资源、再创建业务对象时:上传响应保存 `{cfgId, domainId, url, fileName}`
原始元组,用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
失败静默(孤儿只是自己租户里一张无入口小图)。创建请求引用了它且返回 2xx
(含 `201 + setupWarning`)即视为已采用不再清理;清理复杂度须与资源价值匹配,
勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。
域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。
- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载)
- 写请求(非 GET)在 `request.ts` 层按 method+url+body 做 **in-flight 去重**(2026-07-23 起):前一发未返回时同 key 复用同一 Promise 不再发第二发,完成即移除;GET 不去重(useAsync seq 后发优先)。这是防连点的系统级兜底,组件级 busy/disabled 仍必须做(见 typescript-vue.md);合法的「连续两次相同写」(极少)需改变 body 或等待前次完成。
## 场景:会话换发与并发 401
### 1. Scope / Trigger
后端成功后会令旧 JWT 失效并返回新会话的认证写接口(改密、登录策略、TOTP、
身份/通行密钥变更、撤销全部会话及钱包绑定)必须使用本契约。它防止并发旧请求的
401 先到时清空即将换发的新会话;不用于普通写接口,也不把所有写请求全局串行化。
### 2. Signatures
```ts
interface RequestOptions {
refreshesSession?: boolean
}
request<SessionRefresh>(path, {
method: 'POST',
body,
refreshesSession: true,
})
```
`SessionRefresh` 的成功响应必须包含字符串 `token``expiresAt`
### 3. Contracts
- 有旧 Token 的 `refreshesSession` 请求按“发出时 Token”登记在途换发。
- 2xx 响应须在 Promise 返回、换发登记释放前由请求层先写入 Auth Store;组件可为
mock 流程重复写同值。
- 其他请求的 401 若发现同一旧 Token 仍有换发在途,须等待全部结束后再比较 Store;
Token 已变化则只抛业务错误,不登出。
- 换发请求自身收到 401 时先幂等退出登记再处理 401,避免两个失败换发互相等待。
- 公开登录没有旧 Token,不由请求层提前建立会话;页面仍负责登录后导航。
### 4. Validation & Error Matrix
| 条件 | 行为 |
| --- | --- |
| 2xx 且 `token` / `expiresAt` 合法 | 先 `setSession`,再释放等待者 |
| 网络错误、畸形 JSON、非 2xx | 不写 Store,必须释放登记 |
| 并发 401 等待后 Token 已变化 | 保留新会话,不跳登录页 |
| 等待后仍是请求时 Token | 登出并带当前地址跳转登录页 |
| 多个换发请求均返回 401 | 全部拒绝,不死锁,只执行一次有效登出 |
### 5. Good / Base / Bad Cases
- Good:改密成功响应与旧列表请求 401 并发,成功响应先落 Store,旧 401 不登出。
- Base:没有换发在途的当前 Token 收到 401,按原流程登出。
- Bad:仅在组件 `await` 后写 StorePromise `finally` 与组件 continuation 之间存在
微任务窗口,迟到 401 可抢先登出。
### 6. Tests Required
- 401 先登记等待、换发随后成功:断言 Store 为新 Token 且不导航。
- 换发 Promise 完成边界:断言返回调用方前 Store 已更新。
- 两个换发请求同时 401:断言均结束、无死锁、仅一次导航。
- 无旧 Token 的公开登录:断言请求层不提前写 Store。
### 7. Wrong vs Correct
```ts
// Wrong:后端会换 Token,却没有加入协调
return request('/auth/totp/activate', { method: 'POST', body })
// Correct:请求层在释放并发 401 前先落新会话
return request('/auth/totp/activate', {
method: 'POST',
body,
refreshesSession: true,
})
```
+24
View File
@@ -0,0 +1,24 @@
# 按钮规范
设计稿:统一设计项目「组件-按钮规范.dc.html」;完整决策档案见 `.trellis/tasks/archive/**/07-24-button-spec-rollout/design.md`
## 七个合法变体(新增按钮先对号入座,禁用 secondary / tertiary / dashed / warning)
| 写法 | 用途 |
|---|---|
| `type="primary"` | 区块唯一主操作(保存/登录/新建);一个区块至多一个 |
| (无 type) | 并列次操作(取消/测试/上一步) |
| `quaternary` | 行内轻操作(修改/复制/分页);hover wash 底 |
| `quaternary type="primary"` | 行内肯定动作(绑定/启用);一行至多一个 |
| `quaternary type="error"` | 行内危险入口(删除/解绑);必接 ConfirmPop |
| `type="error"` | 危险最终确认;仅确认层/危险区收尾 |
| `ghost type="error"` | 详情页危险区入口(终止/删除资源) |
## 规则
- 行内(卡片头/表格/列表行尾)一律文字按钮;危险两段式:入口文字红或描边红 → 确认层内才出 Error 实底。
- 尺寸跟容器:页面级工具栏 medium;弹窗底部/筛选条/区块工具行/详情操作条 small;卡片行内/表格 tiny;登录页 large+block。同一容器不混档。
- 图标按钮方形(quaternary 去 circle)且必带 title/tooltip;circle 仅浮层关闭与 VNC 媒体控制。
- 异步必上 loading,宽度稳定、禁重复提交;成功后才关弹层。
- 互斥单选用 `ChoiceChip`(components/ChoiceChip.vue),不用 NButton 动态 type 模拟。
- pressed 色已入 token:accentPressed / errHover / errPressed(tokens.ts + main.css 双处同步)。
+3 -1
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/`
---
@@ -17,6 +17,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
| [Directory Structure](./directory-structure.md) | src 分层、文件命名与全局作用域约定 |
| [TypeScript & Vue](./typescript-vue.md) | TS 严格模式与组件写法 |
| [Styling & Design Tokens](./styling-design-tokens.md) | Anthropic 风设计 token、字体与反模式清单 |
| [Buttons](./buttons.md) | 按钮七变体、尺寸档位与危险两段式守则 |
| [API & State](./api-and-state.md) | 请求封装与 Pinia 使用边界 |
---
@@ -37,6 +38,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
```bash
npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier
npm run typecheck # vue-tsc --noEmit
npm run test # vitest(happy-dom),*.test.ts 与被测模块同目录
npm run build # 类型检查 + 产物构建
```
@@ -71,6 +71,16 @@
- **浮层投影**:下拉/确认弹层/整页提示卡统一 `shadow-overlay`(@theme `--shadow-overlay`),圆角不超过 `rounded-lg`;禁止再写 `shadow-[0_18px…]` 一类逐处任意值。
- **恒暗页面**(网页 VNC 等):色值从 `@/theme/tokens``darkTokens` 取,不硬编码 hex;需要给组件 prop 传具体色时同样从 tokens/darkTokens 引。
## 移动端形态约定(2026-07-21 移动端适配 B0-B4 固化)
- **断点**:`md`(768px)为移动/桌面切换线,JS 层条件渲染一律 `useIsMobile()`(与 CSS 断点同源);`lg` 仅做网格降列。
- **弹窗**:表单弹窗一律 `FormModal`(移动端自动全屏 + 页脚常驻);直接用原生 NModal 的(详情查看类)接 `useMobileModal(width)``:style``:class`,全屏布局规则 `.form-modal-mobile``main.css`(全局,teleport 与跨 chunk 都够得到)。组件 scoped 里给弹窗内容限高须包 `@media (min-width: 768px)`——main.css 先于组件样式加载,同特异性覆盖不可靠。
- **表格**:所有 NDataTable 必配 `scroll-x`(内容合计宽);受控分页 reactive 对象加 `get simple() { return isMobile.value }`(移动端页码槽放不下);高频列表页(租户/实例式)另供 `MobileCardList` 卡片形态,表格 `v-if="!isMobile"`
- **筛选器**:多控件筛选一律包 `FilterBar`(桌面 wrap、移动横滚+右缘渐隐);面板头单控件筛选桌面放 `PanelHeader` 插槽、移动独立行(`v-if="isMobile"``border-b px-4.5 py-2.5` 全宽行)。
- **详情页 hero**:panel 内「返回链接 → 标题行(page-title+徽标+flex-1+按钮组)→ 移动折叠 toggle → meta grid」四段式;操作按钮组包 `flex items-center gap-2 max-md:w-full`,移动端整组独立成行。
- **导航**:顶级页面进 `layouts/navItems.ts`(侧栏/底栏/「更多」抽屉唯一来源);新增页面须归入 `mobileTabNames`(高频四项)或 `mobileMoreNames`
- **构建**:新增大体积三方库须在 `vite.config.ts``vendorChunk` 归入命名组(office 系深依赖靠 importer 链上溯),否则聚成匿名 index 块逃逸 PWA 预缓存排除(`globIgnores: office-*`)并可能触发 chunk 告警。
## Common Mistake: NModal 宽度不能用 Tailwind class
**Symptom**:`<NModal preset="card" class="w-[480px]">` 弹窗实际渲染为全宽。
+2 -1
View File
@@ -14,7 +14,8 @@
- `v-for` 必须带 `key`,且不与 `v-if` 写在同一元素上。
- 组件内样式一律 `scoped`;跨组件复用样式上提到全局 token 或工具类。
- 复用逻辑抽 composable(`useXxx` 命名,放 `composables/`);单文件组件函数遵循 ≤30 行的项目约定。
- 二次确认一律用 `components/ConfirmPop.vue`(批次8 起替代裸 NPopconfirm):kind 分 danger/warn/plain 三态,`onConfirm` 返回 Promise 时确认按钮自动 loading 且弹层锁定;双按钮抉择场景(如终止实例选是否保留引导卷)仍用 `dialog.warning`
- 二次确认一律用 `components/ConfirmPop.vue`(批次8 起替代裸 NPopconfirm):kind 分 danger/warn/plain 三态,`onConfirm` 返回 Promise 时确认按钮自动 loading 且弹层锁定(onConfirm 内部必须自行 catch,不得向外抛)。`dialog.warning` 例外仅限两类:多分支抉择(如终止实例选是否保留引导卷)与下拉菜单项触发(popover 无锚点),此时 onPositiveClick 返回 Promise 让确认键自带 loading;NDialog 其余只用于结果展示
- 写操作触发器(按钮/开关)必须有 in-flight 防连点(按钮交互一致性任务 2026-07-23 起):单动作用 busy ref;列表行内用 `ref(new Set<id>())` 按行 loading/disabled;同行多动作用 `Map<id, action>` 区分,避免错按钮亮 loading。中断性电源操作(停止/重启)与改登录策略类操作必须 ConfirmPop(warn)确认。
- 文件类型图标用 `components/objectstorage/FileIcon.vue`(Catppuccin Icons):图标数据由 `node scripts/gen-file-icons.mjs` 从 devDependency `@iconify-json/catppuccin` 提取到 `fileIcons.ts`(生成文件勿手改);新增类型改脚本 PICK 列表后再生成,禁止整包 import iconify JSON 进 bundle。
- highlight.js 高亮输出的配色是全局 `.code-hl` 类(`assets/main.css`,引用设计 token 明暗自适应),给容器加该类即可,勿在组件 scoped 里重复定义;markdown 渲染必须 marked + DOMPurify 消毒后再 v-html,两库一律动态 import 保持独立 chunk。
- 对象内容数据面分两路(round11 起):预览/编辑走面板中转 `getObjectContent`/`putObjectContent`(GET ≤20MB / PUT ≤5MB,保存以 If-Match 防并发,412 走覆盖确认),**不签发 PAR**;分享/下载/上传仍走 PAR 直连,勿把大流量改经面板。对象查看统一入口是 `ObjectViewerModal`(预览编辑合一,左内容右信息),office/pdf 用 `@vue-office/*` 且必须 `defineAsyncComponent` 动态加载保持独立 chunk。
@@ -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.
---
## Why Thinking Guides?
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
- Didn't think about what happens at layer boundaries → cross-layer bugs
- Didn't think about code patterns repeating → duplicated code everywhere
- Didn't think about edge cases → runtime errors
- Didn't think about future maintainers → unreadable code
These guides help you **ask the right questions before coding**.
---
> 跨仓库的工作纪律与流程约定;各层编码规范见 `oci-portal/backend/` 与 `oci-portal-dash/frontend/`。
## Available Guides
| Guide | Purpose | When to Use |
|-------|---------|-------------|
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
| Guide | 内容 | 何时读 |
|-------|------|--------|
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 |
---
## AI 交叉评审结果核验
## Quick Reference: Thinking Triggers
- 评审称「用户输入可能是恶意的」→ 先查实际数据源:内部清单?用户配置?外部 API?
- 评审标「缺少校验」→ 数据是否来自可信内部源?
- 评审称「行为变化」→ 读代码注释——是否本就是有意设计?
- 评审指测试有 bug → 心里删掉被测功能,测试还过吗?过 = 同义反复测试
### When to Think About Cross-Layer Issues
常见误报模式:信任边界混淆(把内置数据当外部输入)、无视设计注释、变量误读(未追到实际定义)。
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
- [ ] Data format changes between layers
- [ ] Multiple consumers need the same data
- [ ] You're not sure where to put some logic
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
- [ ] UI / command code starts casting raw payload fields directly
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
### When to Think About Code Reuse
- [ ] You're writing similar code to something that exists
- [ ] You see the same pattern repeated 3+ times
- [ ] You're adding a new field to multiple places
- [ ] **You're modifying any constant or config**
- [ ] **You're creating a new utility/helper function** ← Search first!
- [ ] Two files read the same untyped payload field with local casts
- [ ] Multiple branches update the same derived state from `kind` / `action`
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
### When Verifying AI Cross-Review Results
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
**Common AI reviewer false-positive patterns**:
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
**核验规则**:每条 CRITICAL/WARNING 结论必须对照实际代码核验后再排优先级;AI 评审按 ~35% 误报率做预算。
---
## Pre-Modification Rule (CRITICAL)
> **Before changing ANY value, ALWAYS search first!**
```bash
# Search for the value you're about to change
grep -r "value_to_change" .
```
This single habit prevents most "forgot to update X" bugs.
---
## How to Use This Directory
1. **Before coding**: Skim the relevant thinking guide
2. **During coding**: If something feels repetitive or complex, check the guides
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
---
## Contributing
Found a new "didn't think of that" moment? Add it to the relevant guide.
---
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
2026-07 已删除上游模板自带的两个通用 thinking guide(code-reuse / cross-layer):内容为通用常识与 Trellis 自身开发案例,与本项目无关。项目教训一律直接沉淀到对应层的规范文件,不建通用思维文档。
+20 -19
View File
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
- 1.2 Research `[optional · repeatable]`
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip)
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
- 1.5 Completion criteria
@@ -220,7 +220,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef
therefore must cover every required step from implementation through
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Gemini/Qoder/Copilot/ZCode/Reasonix/Trae and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions.
[workflow-state:in_progress]
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
- Planning or unclear requirements -> `trellis-brainstorm`.
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
Spawn the research sub-agent:
@@ -361,11 +361,11 @@ Spawn the research sub-agent:
- **Task description**: Research <specific question>
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.)
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session.
[/codex-inline, Kilo, Antigravity, Devin]
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
#### 1.3 Configure context `[required · once]`
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
Skip this step only when both files already have real curated entries.
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
| `design.md` exists (complex tasks) | ✅ |
| `implement.md` exists (complex tasks) | ✅ |
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
---
@@ -472,21 +472,22 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
#### 2.1 Implement `[required · repeatable]`
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
Spawn the implement sub-agent:
- **Agent type**: `trellis-implement`
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
The platform hook/plugin auto-handles:
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
Spawn the implement sub-agent:
@@ -498,7 +499,7 @@ The pull-based sub-agent definition auto-handles the context load requirement:
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
[/codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
[Kiro]
@@ -526,13 +527,13 @@ The platform prelude auto-handles the context load requirement:
#### 2.2 Quality check `[required · repeatable]`
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
Spawn the check sub-agent:
- **Agent type**: `trellis-check`
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
The check agent's job:
- Review code changes against specs
@@ -540,7 +541,7 @@ The check agent's job:
- Auto-fix issues it finds
- Run lint and typecheck to verify
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
[codex-inline, Kilo, Antigravity, Devin]
+46
View File
@@ -2,6 +2,52 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.8.3]
### Added
- 登录页新增通行密钥与 Web3 钱包免密登录;设置 · 安全重构为密码登录、免密登录、登录方式配置与活跃会话分区,支持管理通行密钥、绑定钱包,并查看及定点撤销其他设备会话
### Changed
- 全面统一按钮变体与按下态,删除、停用、实例电源操作等写操作统一补齐二次确认、加载态和防重复触发;相同写请求在飞行期复用同一结果
- IAM 用户新建 API Key 后均可切换为面板签名凭据,切换到其他用户时明确提示权限变化并同步刷新签名用户状态
### Fixed
- 修复敏感操作换发新令牌后,迟到的旧请求 401 可能清除当前新会话;现在仅当失败请求使用的仍是当前令牌时才退出登录
## [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]
### Added
+1 -1
View File
@@ -23,7 +23,7 @@ oci-portal-dashOCI Portal 前端(Vue 3 + Vite + TypeScript + Naive UI + Tai
## 质量门(提交前必过)
```bash
npm run lint && npm run typecheck && npm run build
npm run lint && npm run typecheck && npm run test && npm run build
```
## 文档同步
+1
View File
@@ -18,6 +18,7 @@ npm run dev # http://localhost:5173,默认 mock 模式开箱可视
```bash
npm run lint # ESLint + Prettier(带 --fix)
npm run typecheck # vue-tsc --noEmit
npm run test # vitest 单元测试(happy-dom 环境)
npm run build # 类型检查 + 产物构建到 dist/
```
+466 -2
View File
@@ -1,12 +1,12 @@
{
"name": "oci-portal-dash",
"version": "0.2.0",
"version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "oci-portal-dash",
"version": "0.2.0",
"version": "0.8.3",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
@@ -21,6 +21,7 @@
"@codemirror/legacy-modes": "^6.5.3",
"@novnc/novnc": "^1.7.0",
"@prettier/plugin-xml": "^3.4.2",
"@simplewebauthn/browser": "^13.3.0",
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10",
@@ -50,11 +51,13 @@
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"happy-dom": "^20.11.0",
"prettier": "^3.9.5",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.10",
"vue-tsc": "^2.2.0"
}
},
@@ -3412,6 +3415,19 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
@@ -3719,6 +3735,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -3772,6 +3806,23 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.62.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
@@ -4029,6 +4080,129 @@
"vue": "^3.2.25"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.10",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.10",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"@vitest/utils": "4.1.10",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.10",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.15",
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
@@ -4497,6 +4671,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -4695,6 +4879,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/buffer-image-size": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -4776,6 +4973,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -5300,6 +5507,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -5797,6 +6011,16 @@
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"license": "MIT"
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -6266,6 +6490,25 @@
"dev": true,
"license": "ISC"
},
"node_modules/happy-dom": {
"version": "20.11.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz",
"integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": ">=20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@types/ws": "^8.18.1",
"buffer-image-size": "^0.6.4",
"entities": "^7.0.1",
"whatwg-mimetype": "^3.0.0",
"ws": "^8.21.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -7631,6 +7874,20 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -7780,6 +8037,13 @@
"node": "20 || >=22"
}
},
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8417,6 +8681,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -8480,6 +8751,20 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8750,6 +9035,23 @@
"node": ">=10"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -8798,6 +9100,16 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -9279,6 +9591,109 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vitest": {
"version": "4.1.10",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
"@vitest/pretty-format": "4.1.10",
"@vitest/runner": "4.1.10",
"@vitest/snapshot": "4.1.10",
"@vitest/spy": "4.1.10",
"@vitest/utils": "4.1.10",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.10",
"@vitest/browser-preview": "4.1.10",
"@vitest/browser-webdriverio": "4.1.10",
"@vitest/coverage-istanbul": "4.1.10",
"@vitest/coverage-v8": "4.1.10",
"@vitest/ui": "4.1.10",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
@@ -9448,6 +9863,16 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -9553,6 +9978,23 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -9824,6 +10266,28 @@
"workbox-core": "7.4.1"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
+5 -1
View File
@@ -1,7 +1,7 @@
{
"name": "oci-portal-dash",
"private": true,
"version": "0.8.0",
"version": "0.8.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,6 +9,7 @@
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --fix",
"test": "vitest run",
"format": "prettier --write src/"
},
"dependencies": {
@@ -25,6 +26,7 @@
"@codemirror/legacy-modes": "^6.5.3",
"@novnc/novnc": "^1.7.0",
"@prettier/plugin-xml": "^3.4.2",
"@simplewebauthn/browser": "^13.3.0",
"@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10",
@@ -54,11 +56,13 @@
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"happy-dom": "^20.11.0",
"prettier": "^3.9.5",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.10",
"vue-tsc": "^2.2.0"
}
}
+174 -24
View File
@@ -1,3 +1,10 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser'
import { mockOn, mocked, request } from './request'
import type {
CredentialsInfo,
@@ -5,6 +12,9 @@ import type {
LoginRequest,
LoginResponse,
OAuthSettings,
PasskeyCeremonyOptions,
PasskeyInfo,
SessionItem,
SessionRefresh,
TotpSetup,
TotpStatus,
@@ -32,7 +42,36 @@ export function logout(): Promise<void> {
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
export function revokeSessions(): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/revoke-sessions', { method: 'POST' })
return request('/auth/revoke-sessions', { method: 'POST', refreshesSession: true })
}
/** mock 会话样例:相对当前时间偏移,保证「最近活跃」展示合理 */
function mockSessions(): { items: SessionItem[] } {
const ago = (min: number) => new Date(Date.now() - min * 60000).toISOString()
const later = new Date(Date.now() + 23 * 3600000).toISOString()
const mk = (id: number, method: string, clientIp: string, userAgent: string, seen: number, created: number, current = false): SessionItem => ({
id, method, clientIp, userAgent,
createdAt: ago(created), lastSeenAt: ago(seen), expiresAt: later, current,
})
return {
items: [
mk(1, 'password', '198.51.100.7', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36', 0, 180, true),
mk(2, 'passkey', '203.0.113.24', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', 185, 205),
mk(3, 'wallet', '192.0.2.88', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0', 780, 800),
],
}
}
/** 活跃会话列表:未撤销、未过期、版本为当前,最近活跃在前 */
export function listSessions(): Promise<{ items: SessionItem[] }> {
if (mockOn) return mocked(mockSessions(), 300)
return request('/auth/sessions')
}
/** 定点撤销一个其他会话;当前会话不可撤销(请走退出登录) */
export function revokeSession(id: number): Promise<void> {
if (mockOn) return mocked(undefined, 250)
return request(`/auth/sessions/${id}`, { method: 'DELETE' })
}
// ---- 登录凭据 ----
@@ -45,13 +84,17 @@ export function getCredentials(): Promise<CredentialsInfo> {
/** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400)
return request('/auth/credentials', { method: 'PUT', body })
return request('/auth/credentials', { method: 'PUT', body, refreshesSession: true })
}
/** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */
export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/password-login', { method: 'PUT', body: { disabled } })
return request('/auth/password-login', {
method: 'PUT',
body: { disabled },
refreshesSession: true,
})
}
// ---- 两步验证(TOTP) ----
@@ -74,22 +117,29 @@ export function setupTotp(): Promise<TotpSetup> {
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
export function activateTotp(code: string): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/activate', { method: 'POST', body: { code } })
return request('/auth/totp/activate', {
method: 'POST',
body: { code },
refreshesSession: true,
})
}
/** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */
export function disableTotp(body: { password?: string; code?: string }): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/totp/disable', { method: 'POST', body })
return request('/auth/totp/disable', { method: 'POST', body, refreshesSession: true })
}
// ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[]
passwordLoginDisabled: boolean
passkeyLogin: boolean
walletLogin: boolean
}> {
if (mockOn)
return mocked({
@@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{
{ provider: 'oidc', displayName: 'OIDC SSO' },
],
passwordLoginDisabled: false,
passkeyLogin: true,
walletLogin: true,
})
return request('/auth/oauth/providers')
}
@@ -124,14 +176,113 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
/** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */
export function unbindIdentity(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/identities/${id}`, { method: 'DELETE' })
return request(`/auth/identities/${id}`, { method: 'DELETE', refreshesSession: true })
}
// ---- 通行密钥(Passkey) ----
const mockPasskeyRegisterOptions: PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON> = {
sessionId: 'mock-session',
options: {
publicKey: {
challenge: 'bW9jaw',
rp: { id: 'localhost', name: 'OCI Portal' },
user: { id: 'AQ', name: 'admin', displayName: 'admin' },
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
},
},
}
/** 发起通行密钥注册;sessionId 需原样带回 finish */
export function beginPasskeyRegister(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON>
> {
if (mockOn) return mocked(mockPasskeyRegisterOptions, 300)
return request('/auth/passkey/register/begin', { method: 'POST' })
}
/** 完成通行密钥注册;成功后旧 token 全部失效,响应带新会话 */
export function finishPasskeyRegister(
sessionId: string,
name: string,
credential: RegistrationResponseJSON,
): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/register/finish', {
method: 'POST',
body: { sessionId, name, credential },
refreshesSession: true,
})
}
export function listPasskeys(): Promise<{ items: PasskeyInfo[] }> {
if (mockOn)
return mocked({
items: [
{ id: 1, name: 'MacBook Touch ID', createdAt: '2026-07-20T10:00:00+08:00', lastUsedAt: null },
],
})
return request('/auth/passkeys')
}
/** 删除通行密钥;成功后旧 token 全部失效,响应带新会话 */
export function removePasskey(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request(`/auth/passkeys/${id}`, { method: 'DELETE', refreshesSession: true })
}
/** 发起通行密钥登录(公开接口) */
export function beginPasskeyLogin(): Promise<
PasskeyCeremonyOptions<PublicKeyCredentialRequestOptionsJSON>
> {
if (mockOn)
return mocked({
sessionId: 'mock-session',
options: { publicKey: { challenge: 'bW9jaw', rpId: 'localhost' } },
})
return request('/auth/passkey/login/begin', { method: 'POST' })
}
/** 完成通行密钥登录;UV 通过后豁免 TOTP */
export function finishPasskeyLogin(
sessionId: string,
credential: AuthenticationResponseJSON,
): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/passkey/login/finish', {
method: 'POST',
body: { sessionId, credential },
})
}
// ---- Web3 钱包(SIWE) ----
/** 发起钱包签名挑战;mode=bind 要求已登录,message 需原样 personal_sign */
export function getWalletChallenge(
address: string,
mode: 'login' | 'bind',
): Promise<{ nonce: string; message: string }> {
if (mockOn)
return mocked({
nonce: 'mock-nonce',
message: `localhost wants you to sign in with your Ethereum account:\n${address}\n\n登录 OCI Portal 面板\n\nNonce: mock-nonce`,
})
return request('/auth/wallet/challenge', { method: 'POST', body: { address, mode } })
}
/** 校验钱包签名:login 返回新会话;bind 返回换发的新 token(旧会话已失效) */
export function verifyWallet(nonce: string, signature: string): Promise<LoginResponse> {
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
return request('/auth/wallet/verify', {
method: 'POST',
body: { nonce, signature },
refreshesSession: true,
})
}
// ---- OAuth provider 配置(设置页) ----
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn)
return mocked({
const mockOauth: OAuthSettings = {
oidcIssuer: '',
oidcClientId: '',
oidcSecretSet: false,
@@ -141,22 +292,21 @@ export function getOAuthSettings(): Promise<OAuthSettings> {
githubSecretSet: false,
githubDisplayName: '',
githubDisabled: false,
})
}
export function getOAuthSettings(): Promise<OAuthSettings> {
if (mockOn) return mocked({ ...mockOauth })
return request('/settings/oauth')
}
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
if (mockOn)
return mocked({
oidcIssuer: body.oidcIssuer,
oidcClientId: body.oidcClientId,
oidcSecretSet: !!body.oidcClientSecret,
oidcDisplayName: body.oidcDisplayName,
oidcDisabled: body.oidcDisabled,
githubClientId: body.githubClientId,
githubSecretSet: !!body.githubClientSecret,
githubDisplayName: body.githubDisplayName,
githubDisabled: body.githubDisabled,
})
return request('/settings/oauth', { method: 'PUT', body })
if (mockOn) {
const { oidcClientSecret, githubClientSecret, ...rest } = body
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
return mocked({ ...mockOauth })
}
return request('/settings/oauth', { method: 'PATCH', body })
}
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { terminateInstance, terminatingInstances, terminatingKey } from './instances'
/** 可控完成时机的 fetch stub;fail 控制 resolve 后是成功还是 500 */
function stubTerminateFetch(fail = false) {
const calls = { count: 0 }
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
vi.stubGlobal('fetch', async () => {
calls.count++
await gate
if (fail) {
return {
ok: false,
status: 500,
url: '/api/v1/x',
json: async () => ({ error: '终止失败' }),
} as unknown as Response
}
return { ok: true, status: 200, text: async () => '' } as unknown as Response
})
return { calls, release }
}
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
terminatingInstances.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('共享终止锁', () => {
it('请求飞行中 key 在集合内,完成后移除', async () => {
const { release } = stubTerminateFetch()
const p = terminateInstance(3, 'ocid1.instance..x', false)
expect(terminatingInstances.has(terminatingKey(3, 'ocid1.instance..x'))).toBe(true)
release()
await p
expect(terminatingInstances.size).toBe(0)
})
it('请求失败同样释放锁,不留脏状态', async () => {
const { release } = stubTerminateFetch(true)
const p = terminateInstance(3, 'ocid1.instance..x', true)
expect(terminatingInstances.size).toBe(1)
release()
await expect(p).rejects.toMatchObject({ message: '终止失败' })
expect(terminatingInstances.size).toBe(0)
})
it('同一物理实例跨配置共享 key,后到调用被拒', async () => {
const { calls, release } = stubTerminateFetch()
const instanceId = 'ocid1.instance.oc1..same'
const first = terminateInstance(1, instanceId, false)
await expect(terminateInstance(2, instanceId, true)).rejects.toMatchObject({ status: 409 })
expect(terminatingKey(1, instanceId)).toBe(terminatingKey(2, instanceId))
expect(calls.count).toBe(1)
release()
await first
})
it('不同实例互不影响', async () => {
const { release } = stubTerminateFetch()
const pa = terminateInstance(1, 'a', false)
const pb = terminateInstance(2, 'b', false)
expect(terminatingInstances.has(terminatingKey(1, 'a'))).toBe(true)
expect(terminatingInstances.has(terminatingKey(2, 'b'))).toBe(true)
expect(terminatingKey(1, 'a')).not.toBe(terminatingKey(2, 'b'))
release()
await Promise.all([pa, pb])
expect(terminatingInstances.size).toBe(0)
})
})
+33 -3
View File
@@ -8,7 +8,9 @@ import {
mockVolAttachments,
mockVolumes,
} from './mock'
import { mockOn, mocked, request } from './request'
import { reactive } from 'vue'
import { ApiError, mockOn, mocked, request } from './request'
import type {
AttachVnicRequest,
BootVolumeAttachment,
@@ -118,13 +120,41 @@ export function updateInstance(
})
}
export function terminateInstance(
/** 终止请求飞行中的实例(instance OCID):模块级响应式集合,列表页、
* 详情页与组件重挂载共用一把锁,防「关闭重开对话框后用相反的
* preserveBootVolume 并发提交」;跨浏览器页签由服务端同键 guard 兜底 */
export const terminatingInstances = reactive(new Set<string>())
/** Instance OCID 唯一标识物理实例;cfgId 不得拆成两把终止锁 */
export function terminatingKey(_cfgId: number, instanceId: string): string {
return instanceId
}
export async function terminateInstance(
cfgId: number,
instanceId: string,
preserveBootVolume: boolean,
region?: string,
): Promise<void> {
const key = terminatingKey(cfgId, instanceId)
if (terminatingInstances.has(key)) {
throw new ApiError(409, '该实例的终止请求正在处理中')
}
terminatingInstances.add(key)
try {
if (mockOn) return await mocked(undefined, 600)
return await requestTerminate(cfgId, instanceId, preserveBootVolume, region)
} finally {
terminatingInstances.delete(key)
}
}
function requestTerminate(
cfgId: number,
instanceId: string,
preserveBootVolume: boolean,
region?: string,
): Promise<void> {
if (mockOn) return mocked(undefined, 600)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
method: 'DELETE',
query: { preserveBootVolume, region },
+230
View File
@@ -0,0 +1,230 @@
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
})
}
function stubRefreshThen401() {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
vi.stubGlobal('fetch', async (input: RequestInfo | URL) => {
if (String(input).endsWith('/refresh')) {
await gate
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
}
return {
ok: false,
status: 401,
json: async () => ({ error: '令牌已失效' }),
} as unknown as Response
})
return release
}
let assignSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
/** 可控完成时机的 fetch stub,记录调用次数 */
function stubSlowFetch() {
const calls = { count: 0 }
let release!: () => void
const gate = new Promise<void>((r) => (release = r))
vi.stubGlobal('fetch', async () => {
calls.count++
await gate
return { ok: true, status: 200, text: async () => '{"ok":true}' } as unknown as Response
})
return { calls, release }
}
describe('写请求 in-flight 去重', () => {
it('并发相同写请求只发一次,共享同一结果', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
const p2 = request('/y', { method: 'POST', body: { a: 1 } })
release()
expect(await Promise.all([p1, p2])).toEqual([{ ok: true }, { ok: true }])
expect(calls.count).toBe(1)
})
it('body 不同不去重;完成后同 key 可再发', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
const p2 = request('/y', { method: 'POST', body: { a: 2 } })
release()
await Promise.all([p1, p2])
expect(calls.count).toBe(2)
await request('/y', { method: 'POST', body: { a: 1 } })
expect(calls.count).toBe(3)
})
it('GET 不去重', async () => {
const { calls, release } = stubSlowFetch()
const p1 = request('/y')
const p2 = request('/y')
release()
await Promise.all([p1, p2])
expect(calls.count).toBe(2)
})
})
/** 构造 429 响应;body 由调用方给定(带不带 code 决定分流) */
function stub429(body: Record<string, unknown>) {
vi.stubGlobal('fetch', async () => {
return {
ok: false,
status: 429,
url: '/api/v1/x',
json: async () => body,
} as unknown as Response
})
}
describe('429 分流', () => {
it('全局 IP 限流(code=RateLimited):整页跳转 /blocked', async () => {
setPageURL('http://localhost/instances')
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
await expect(request('/x')).rejects.toMatchObject({ status: 429 })
expect(assignSpy).toHaveBeenCalledWith('/blocked')
})
it('登录守卫锁定(无 code):不跳转,留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub429({ error: '尝试过于频繁,请稍后再试' })
await expect(request('/auth/wallet/verify', { method: 'POST', body: {} })).rejects.toMatchObject(
{ status: 429, message: '尝试过于频繁,请稍后再试' },
)
expect(assignSpy).not.toHaveBeenCalled()
})
it('设置页绑定遇全局限流:同样进入 /blocked(不按 URL 判断)', async () => {
setPageURL('http://localhost/settings')
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
await expect(
request('/auth/wallet/challenge', { method: 'POST', body: {} }),
).rejects.toMatchObject({ status: 429 })
expect(assignSpy).toHaveBeenCalledWith('/blocked')
})
})
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 先到时等待并发换新响应落入 Store,不得提前登出', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
const releaseRefresh = stubRefreshThen401()
const refresh = request<{ token: string; expiresAt: string }>('/refresh', {
method: 'POST',
refreshesSession: true,
})
const stale = request('/stale', { method: 'POST' }).catch((error: unknown) => error)
await Promise.resolve()
expect(useAuthStore().token).toBe('tokA')
expect(assignSpy).not.toHaveBeenCalled()
releaseRefresh()
const next = await refresh
expect(next.token).toBe('tokB')
expect(await stale).toBeInstanceOf(ApiError)
expect(useAuthStore().token).toBe('tokB')
expect(assignSpy).not.toHaveBeenCalled()
})
it('换发 Promise 完成时已先写 Store,不给迟到 401 留微任务窗口', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
vi.stubGlobal('fetch', async () => {
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
})
await request('/refresh', { method: 'POST', refreshesSession: true })
expect(useAuthStore().token).toBe('tokB')
})
it('公开登录没有旧 token 时仍由页面接管新会话', async () => {
vi.stubGlobal('fetch', async () => {
return {
ok: true,
status: 200,
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
} as unknown as Response
})
await request('/login', { method: 'POST', refreshesSession: true })
expect(useAuthStore().token).toBe('')
})
it('并发换新请求都返回 401 时不得互相等待', async () => {
setPageURL('http://localhost/settings')
useAuthStore().setSession('tokA', '')
stub401()
const opts = { method: 'POST', refreshesSession: true } as const
const results = await Promise.allSettled([
request('/refresh-a', opts),
request('/refresh-b', opts),
])
expect(results.map((result) => result.status)).toEqual(['rejected', 'rejected'])
expect(useAuthStore().token).toBe('')
expect(assignSpy).toHaveBeenCalledTimes(1)
})
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
setPageURL('http://localhost/login')
stub401()
await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({
message: '未认证',
})
expect(assignSpy).not.toHaveBeenCalled()
})
})
+140 -23
View File
@@ -20,8 +20,27 @@ interface RequestOptions {
query?: Record<string, string | number | boolean | undefined>
/** 附加请求头 */
headers?: Record<string, string>
/** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */
refreshesSession?: boolean
}
interface RefreshWaiter {
resolve: (waited: boolean) => void
}
interface RefreshState {
count: number
waiters: RefreshWaiter[]
}
interface ErrorDetails {
message: string
ociCode?: string
rateLimited: boolean
}
const sessionRefreshes = new Map<string, RefreshState>()
function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return BASE + path
const qs = new URLSearchParams()
@@ -32,50 +51,147 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path
}
async function parseError(resp: Response): Promise<never> {
let message = `请求失败(${resp.status}`
let ociCode: string | undefined
async function parseError(
resp: Response,
sentToken: string,
): Promise<never> {
const details = await readErrorDetails(resp)
if (resp.status === 401) await handleUnauthorized(sentToken)
if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, details.message, details.ociCode)
}
async function readErrorDetails(resp: Response): Promise<ErrorDetails> {
const fallback = { message: `请求失败(${resp.status}`, rateLimited: false }
try {
const body = (await resp.json()) as {
error?: string
hint?: string
errors?: unknown[]
ociCode?: string
code?: string
}
ociCode = body.ociCode
if (body.error) {
message = body.hint ? `${body.hint}${body.error}` : body.error
} else {
// 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并;
// 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
if (list.length) message = list.join('\n')
}
const message = body.error
? body.hint
? `${body.hint}${body.error}`
: body.error
: list.join('\n') || fallback.message
return { message, ociCode: body.ociCode, rateLimited: body.code === 'RateLimited' }
} catch {
/* 非 JSON 响应体,保留默认消息 */
return fallback
}
if (resp.status === 401) useAuthStore().logout()
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message, ociCode)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
async function handleUnauthorized(sentToken: string) {
const waited = await waitForSessionRefresh(sentToken)
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
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}`)
}
}
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
const inflightWrites = new Map<string, Promise<unknown>>()
function beginSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (state) {
state.count++
return
}
sessionRefreshes.set(token, { count: 1, waiters: [] })
}
function endSessionRefresh(token: string) {
const state = sessionRefreshes.get(token)
if (!state) return
state.count--
state.waiters = state.waiters.filter((waiter) => {
if (state.count > 0) return true
waiter.resolve(true)
return false
})
if (state.count === 0) sessionRefreshes.delete(token)
}
function waitForSessionRefresh(token: string): Promise<boolean> {
const state = sessionRefreshes.get(token)
if (!state || state.count === 0) return Promise.resolve(false)
return new Promise((resolve) => state.waiters.push({ resolve }))
}
function trackSessionRefresh(token: string): () => void {
beginSessionRefresh(token)
let active = true
return () => {
if (!active) return
active = false
endSessionRefresh(token)
}
}
function applySessionRefresh(value: unknown) {
if (!value || typeof value !== 'object') return
const refresh = value as { token?: unknown; expiresAt?: unknown }
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
useAuthStore().setSession(refresh.token, refresh.expiresAt)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
* 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise,
* 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。
* 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */
export function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const method = (opts.method ?? 'GET').toUpperCase()
if (method === 'GET') return doRequest<T>(path, opts)
const body = opts.body === undefined ? '' : JSON.stringify(opts.body)
const sentToken = useAuthStore().token ?? ''
const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}`
const existing = inflightWrites.get(key)
if (existing) return existing as Promise<T>
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
inflightWrites.delete(key)
finishRefresh()
})
inflightWrites.set(key, p)
return p
}
async function doRequest<T>(
path: string,
opts: RequestOptions,
finishRefresh: () => void = () => {},
): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) {
if (resp.status === 401) finishRefresh()
await parseError(resp, sentToken)
}
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
const result = (text ? JSON.parse(text) : undefined) as T
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
return result
}
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
@@ -91,12 +207,13 @@ export async function rawFetch(
const auth = useAuthStore()
const headers: Record<string, string> = { ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const sentToken = auth.token
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body,
})
if (!resp.ok) await parseError(resp)
if (!resp.ok) await parseError(resp, sentToken)
return resp
}
+4 -4
View File
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
return request('/settings/security')
}
/** 保存安全设置,返回最新;越界或非法后端返回 400,保存后立即生效 */
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
if (mockOn) {
Object.assign(mockSecuritySetting, body)
Object.assign(mockSecuritySetting, patch)
return mocked({ ...mockSecuritySetting }, 400)
}
return request('/settings/security', { method: 'PUT', body })
return request('/settings/security', { method: 'PATCH', body: patch })
}
export interface SystemLogParams {
+2 -5
View File
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
return request(`/tasks/${id}/logs`, { query: { limit } })
}
export function runTask(id: number): Promise<TaskLog> {
if (mockOn) {
const logs = mockTaskLogs[id]
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
}
export function runTask(id: number): Promise<{ triggered: boolean }> {
if (mockOn) return mocked({ triggered: true }, 300)
return request(`/tasks/${id}/run`, { method: 'POST' })
}
+86 -10
View File
@@ -10,14 +10,16 @@ import {
mockUserDetails,
mockUsers,
} from './mock'
import { mockOn, mocked, request } from './request'
import { mockOn, mocked, rawFetch, request } from './request'
import type {
AuditEventsResult,
CreatedApiKey,
IamUser,
IdentityDomain,
IamUserDetail,
IdentityProvider,
IdentitySetting,
IdpIconUpload,
LimitItem,
LimitService,
NotificationRecipients,
@@ -25,6 +27,7 @@ import type {
SignOnRule,
SubscriptionDetail,
SubscriptionSummary,
UserApiKey,
} from '@/types/api'
// ---- 配额 ----
@@ -168,15 +171,61 @@ export function clearUserMfa(id: number, userId: string, domainId?: string): Pro
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } })
}
export function deleteUserApiKeys(
id: number,
userId: string,
includeCurrent = false,
): Promise<{ deletedApiKeys: number }> {
if (mockOn) return mocked({ deletedApiKeys: 1 })
return request(`/oci-configs/${id}/users/${userId}/api-keys`, {
// ---- 用户 API 签名 Key ----
const mockCreatedKey: CreatedApiKey = {
fingerprint: '11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff',
privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMOCKMOCKMOCK\n-----END RSA PRIVATE KEY-----\n',
configIni:
'[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',
query: { includeCurrent },
})
}
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传;
* userId 给定且异于当前签名用户时,一并把面板签名用户切换为该用户 */
export function activateApiKey(
id: number,
fingerprint: string,
privateKey: string,
userId?: string,
): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/activate-api-key`, {
method: 'POST',
body: { userId, fingerprint, privateKey },
})
}
@@ -298,7 +347,7 @@ export interface CreateIdpBody {
name: string
metadata: string
description?: string
/** logodata URI 或外链 URL */
/** logohttp(s) 外链 URL(可先经 IdP 图标上传接口取得) */
iconUrl?: string
nameIdFormat?: string
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
@@ -325,6 +374,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
}
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
export async function uploadIdpIcon(
id: number,
file: File,
domainId?: string,
): Promise<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[]> {
if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
+13
View File
@@ -15,9 +15,12 @@
--color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */
--color-accent: #c96442;
--color-accent-hover: #ad4f2e;
--color-accent-pressed: #9a4527;
--color-chart: #c15f3c;
--color-ok: #788c5d;
--color-err: #b53333;
--color-err-hover: #9c2b2b;
--color-err-pressed: #872525;
--color-info: #6a9bcc;
--color-warn: #a87514;
@@ -45,9 +48,12 @@ html.dark {
--color-wash-deep: #33312d;
--color-accent: #d97757;
--color-accent-hover: #e08b6d;
--color-accent-pressed: #c4674a;
--color-chart: #d97757;
--color-ok: #8fa574;
--color-err: #d06060;
--color-err-hover: #d97070;
--color-err-pressed: #b85454;
--color-info: #7fabd6;
--color-warn: #c29135;
color-scheme: dark;
@@ -104,6 +110,13 @@ body {
background: color-mix(in srgb, var(--color-ink) 22%, transparent);
}
/* raw 弹层(ConfirmPop / TenantPicker 等自带圆角卡片):Naive 的 .n-popover
在 raw 模式下不加背景与圆角,但 box-shadow 无条件应用——外层直角矩形的
阴影会在内层圆角卡四角露出方角,统一去掉外层阴影(卡片自带 shadow-overlay) */
.n-popover--raw {
box-shadow: none;
}
/* 租户选择器面板(移动端):popover follower 只会翻转不会平移,窄屏下
bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切;
改为覆盖 follower 定位,固定顶部整宽展示(follower 挂 body 下,无
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
/**
* 互斥单选的 chip 按钮(按钮规范第 8 条):选中 accent 描边 + 浅底 + 加粗;
* dashed 为「自定义」扩展位。多选/触发器场景不要用它。
*/
defineProps<{ active?: boolean; dashed?: boolean }>()
</script>
<template>
<button
type="button"
class="rounded-md border px-3 py-1.5 text-xs transition-colors enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
:class="[
active
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2 enabled:hover:border-ink-3',
dashed ? 'border-dashed' : '',
]"
>
<slot />
</button>
</template>
+1
View File
@@ -35,6 +35,7 @@ const modalStyle = computed(() =>
:class="isMobile ? 'form-modal-mobile' : ''"
:mask-closable="!submitting"
:close-on-esc="!submitting"
:closable="!submitting"
@update:show="emit('update:show', $event)"
>
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
+13 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NInput, NModal, useDialog } from 'naive-ui'
import { NButton, NInput, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
@@ -8,6 +8,7 @@ import {
syncAiChannelModels,
testAiChannelModel,
} from '@/api/aigateway'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useMobileModal } from '@/composables/useMobileModal'
import { useToast } from '@/composables/useToast'
import type { AiChannel, AiModelCacheItem } from '@/types/api'
@@ -16,7 +17,6 @@ const props = defineProps<{ show: boolean; channel: AiChannel | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
const toast = useToast()
const dialog = useDialog()
const { modalStyle, modalClass } = useMobileModal(640)
const rows = ref<AiModelCacheItem[]>([])
@@ -99,13 +99,7 @@ async function doTest(name: string) {
}
}
function confirmBlacklist(name: string) {
dialog.warning({
title: '拉黑模型',
content: `${name}」将从全部渠道剔除,同步与探测不再入库;可在模型黑名单中移出后重新同步恢复。`,
positiveText: '拉黑',
negativeText: '取消',
onPositiveClick: async () => {
async function doBlacklist(name: string) {
try {
await addAiBlacklist(name)
toast.success('已拉黑')
@@ -114,8 +108,6 @@ function confirmBlacklist(name: string) {
} catch (e) {
toast.error(e instanceof Error ? e.message : '拉黑失败')
}
},
})
}
</script>
@@ -179,13 +171,22 @@ function confirmBlacklist(name: string) {
>
{{ testing === r.name ? '测试中…' : '测试' }}
</button>
<ConfirmPop
:title="`拉黑模型「${r.name}」?`"
content="将从全部渠道剔除,同步与探测不再入库。"
note="可在模型黑名单中移出后重新同步恢复"
confirm-text="拉黑"
:on-confirm="() => doBlacklist(r.name)"
>
<template #trigger>
<button
type="button"
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs text-ink-3/70 group-hover:text-err hover:bg-err/10"
@click="confirmBlacklist(r.name)"
>
拉黑
</button>
</template>
</ConfirmPop>
</div>
</template>
<div v-if="!shownCount" class="py-10 text-center text-xs text-ink-3">
+25 -13
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway'
import { listCachedRegions } from '@/api/configs'
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import GroupChips from '@/components/ai/GroupChips.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
@@ -25,7 +26,6 @@ const emit = defineEmits<{ changed: [] }>()
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
const toast = useToast()
const dialog = useDialog()
const scope = useScopeStore()
/** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户);
* 名称本身不承载跳转,单元格内截断 */
@@ -135,28 +135,31 @@ function openModels(ch: AiChannel) {
showModels.value = true
}
/** 正在切换启用状态的渠道 id 集合,按行 loading 防连点 */
const toggling = ref(new Set<number>())
async function toggle(ch: AiChannel) {
if (toggling.value.has(ch.id)) return
toggling.value.add(ch.id)
try {
await updateAiChannel(ch.id, { enabled: !ch.enabled })
toast.success(ch.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
} finally {
toggling.value.delete(ch.id)
}
}
function confirmRemove(ch: AiChannel) {
dialog.warning({
title: '删除渠道',
content: `删除渠道「${ch.name}」及其模型缓存?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
async function doRemove(ch: AiChannel) {
try {
await deleteAiChannel(ch.id)
toast.success('已删除')
emit('changed')
},
})
} catch (e) {
toast.error(e instanceof Error ? e.message : '删除失败')
}
}
function probeCell(r: AiChannel) {
@@ -204,9 +207,18 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', loading: probing.value.has(r.id), onClick: () => doProbe(r.id) }, { default: () => '探测' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openModels(r) }, { default: () => '模型列表' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
h(
ConfirmPop,
{
title: `删除渠道「${r.name}」?`,
content: '渠道及其模型缓存将一并删除。',
confirmText: '删除',
onConfirm: () => doRemove(r),
},
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
),
]),
},
])
+26 -13
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
import ConfirmPop from '@/components/ConfirmPop.vue'
import GroupChips from '@/components/ai/GroupChips.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
@@ -17,7 +18,6 @@ const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[]; m
const emit = defineEmits<{ changed: [] }>()
const message = useToast()
const dialog = useDialog()
// ---- 新建 / 编辑弹窗 ----
const showForm = ref(false)
@@ -100,28 +100,31 @@ async function copyKey() {
}
}
/** 正在切换启用状态的密钥 id 集合,按行 loading 防连点 */
const toggling = ref(new Set<number>())
async function toggle(key: AiKey) {
if (toggling.value.has(key.id)) return
toggling.value.add(key.id)
try {
await updateAiKey(key.id, { enabled: !key.enabled })
message.success(key.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
toggling.value.delete(key.id)
}
}
function confirmRemove(key: AiKey) {
dialog.warning({
title: '删除密钥',
content: `删除密钥「${key.name}」?使用该密钥的调用将立即失效。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
async function doRemove(key: AiKey) {
try {
await deleteAiKey(key.id)
message.success('已删除')
emit('changed')
},
})
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const groupHints = computed(() => props.groups.filter(Boolean))
@@ -160,9 +163,18 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
{
title: '操作', key: 'actions', width: 170,
render: (r) => h('div', { class: 'flex items-center gap-2' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
h(
ConfirmPop,
{
title: `删除密钥「${r.name}」?`,
content: '使用该密钥的调用将立即失效。',
confirmText: '删除',
onConfirm: () => doRemove(r),
},
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
),
]),
},
])
@@ -221,6 +233,7 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
<span class="text-xs text-ink-3">小时(上限 7 )</span>
<NButton
size="tiny"
quaternary
:loading="togglingContentLog"
:disabled="!contentLogHours"
@click="setContentLog(contentLogHours ?? 0)"
+15 -1
View File
@@ -35,21 +35,34 @@ let ws: WebSocket | null = null
let ro: ResizeObserver | null = null
let sessionId = ''
const encoder = new TextEncoder()
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
// B 实际连着 A,用户输入发往错误实例
let openSeq = 0
async function open() {
const seq = ++openSeq
phase.value = 'creating'
error.value = ''
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
let created = ''
try {
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
sessionId = res.sessionId
created = res.sessionId
} catch (e) {
if (seq !== openSeq) return
phase.value = 'closed'
error.value = e instanceof Error ? e.message : '创建串行会话失败'
return
}
if (seq !== openSeq) {
void deleteConsoleSession(created).catch(() => undefined)
return
}
sessionId = created
phase.value = 'connecting'
await nextTick()
if (seq !== openSeq) return
mountTerm()
connectWs()
}
@@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) {
}
function teardown() {
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
ro?.disconnect()
ro = null
if (ws) {
+11 -2
View File
@@ -41,16 +41,22 @@ const pagination = reactive({
},
})
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
// 不得覆盖已切换的新页
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch {
// 自动刷新失败静默,手动路径由全局兜底
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
@@ -106,12 +112,15 @@ const showDetail = ref(false)
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
const contentLog = ref<AiContentLog | null>(null)
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
let contentSeq = 0
async function loadContent(callLogId: number) {
const seq = ++contentSeq
contentLog.value = null
try {
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
contentLog.value = items[0] ?? null
if (seq === contentSeq) contentLog.value = items[0] ?? null
} catch {
// 正文加载失败不阻塞元数据详情
}
+7 -1
View File
@@ -64,7 +64,12 @@ const pagination = reactive({
},
})
// : load,( 1 )
//
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listLogEvents({
@@ -72,12 +77,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize,
cfgId: cfgFilter.value ?? undefined,
})
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
+7 -1
View File
@@ -48,7 +48,12 @@ const pagination = reactive({
},
})
// : load,( 1 )
//
let loadSeq = 0
async function load(silent = false) {
const seq = ++loadSeq
if (!silent) loading.value = true
try {
const data = await listSystemLogs({
@@ -56,12 +61,13 @@ async function load(silent = false) {
pageSize: pagination.pageSize,
keyword: applied.value || undefined,
})
if (seq !== loadSeq) return
rows.value = data.items
pagination.itemCount = data.total
} catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
} finally {
if (!silent) loading.value = false
if (!silent && seq === loadSeq) loading.value = false
}
}
@@ -279,7 +279,8 @@ function isEditing(dir: Dir, idx: number): boolean {
<td colspan="5" class="px-3.5 py-1.5">
<NButton
size="tiny"
dashed
quaternary
type="primary"
:disabled="!!editing"
@click="startEdit(dir, -1)"
>
+80 -30
View File
@@ -21,6 +21,7 @@ import {
uploadViaPar,
} from '@/api/objectstorage'
import AppInputNumber from '@/components/AppInputNumber.vue'
import ChoiceChip from '@/components/ChoiceChip.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
@@ -69,9 +70,23 @@ const result = useAsync(async () => {
})
}, false)
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), {
immediate: true,
})
// (//) = , resetScope ;
// /
watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope())
watch([prefix, cursor], () => void result.run(), { immediate: true })
/** :,
* 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */
function resetScope() {
prefix.value = ''
filterInput.value = ''
cursorStack.value = []
cursor.value = ''
checkedKeys.value = []
showPar.value = false
showViewer.value = false
void result.run()
}
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
const POLL_MS = 30_000
@@ -220,14 +235,25 @@ async function cleanupPar(parId: string) {
// ---- : fetch , / ----
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
function willBrowserHandle(size: number): boolean {
return !size || size > DOWN_STREAM_LIMIT
}
function download(name: string, size?: number) {
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
return doDownload(task, name, known)
// :PAR window.open ,
// (,)
const preWin = willBrowserHandle(known) ? window.open('', '_blank') : null
return doDownload(task, name, known, preWin)
}
async function doDownload(task: TransferTask, name: string, size: number) {
if (!props.cfgId) return
async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) {
if (!props.cfgId) {
closeIfBlank(preWin)
return
}
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
@@ -235,34 +261,50 @@ async function doDownload(task: TransferTask, name: string, size: number) {
accessType: 'ObjectRead',
expiresHours: 1,
})
const cleanable = await transferDown(task, par.fullUrl!, size)
const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
task.status = 'done'
task.percent = 100
// (1 ),
if (cleanable) void cleanupPar(par.id)
else parPanel.value?.refresh()
} catch (err) {
// (browserOpen ),
closeIfBlank(preWin)
if ((err as DOMException)?.name === 'AbortError') return
task.status = 'error'
task.error = err instanceof Error ? err.message : '下载失败'
}
}
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
function closeIfBlank(preWin: Window | null) {
if (preWin && !preWin.closed) preWin.close()
}
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
async function transferDown(
task: TransferTask,
url: string,
size: number,
preWin: Window | null,
): Promise<boolean> {
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
try {
const blob = await fetchWithProgress(task, url, size)
saveBlob(blob, task.name)
return true
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') throw err
return browserOpen(task, url)
// 退:,
return browserOpen(task, url, null)
}
}
function browserOpen(task: TransferTask, url: string): boolean {
window.open(url, '_blank')
/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean {
const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank')
if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试')
if (win === preWin) win.location.replace(url)
task.note = '已交由浏览器下载'
return false
}
@@ -360,12 +402,19 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
})
const parUrl = ref('')
const parIsBucket = computed(() => !parForm.objectName)
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24
const parExpiresValid = computed(() => {
const hours = parForm.expiresHours
return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS
})
/** 有效期快捷项;也可直接输入 1 至 876000 小时 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
{ label: '1 天', value: 24 },
{ label: '7 天', value: 168 },
{ label: '30 天', value: 720 },
{ label: '1 年', value: 8760 },
{ label: '10 年', value: 87600 },
]
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
@@ -380,14 +429,15 @@ function openParModal(objectName: string) {
}
async function doCreatePar() {
if (!props.cfgId || !parForm.expiresHours) return
const expiresHours = parForm.expiresHours
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
parBusy.value = true
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: parForm.objectName,
accessType: parForm.accessType,
expiresHours: parForm.expiresHours,
expiresHours,
})
parUrl.value = par.fullUrl ?? ''
parPanel.value?.refresh()
@@ -593,9 +643,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
<div class="flex-1" />
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
<div class="flex items-center gap-2 max-md:w-full">
<NButton size="small" @click="openParModal('')">分享桶</NButton>
<NButton size="small" @click="emit('settings')">桶设置</NButton>
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
<NButton size="medium" @click="openParModal('')">分享桶</NButton>
<NButton size="medium" @click="emit('settings')">桶设置</NButton>
<NButton size="medium" type="primary" @click="pickFiles">上传对象</NButton>
</div>
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
</div>
@@ -685,7 +735,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
>
<span class="font-medium">已选 {{ checkedKeys.length }} </span>
<NButton size="tiny" @click="batchDownload">逐个下载</NButton>
<NButton size="tiny" quaternary @click="batchDownload">逐个下载</NButton>
<ConfirmPop
:title="`删除选中的 ${checkedKeys.length} 个对象?`"
content="未开版本控制时不可恢复。"
@@ -714,8 +764,10 @@ const columns = computed<DataTableColumns<Row>>(() => [
>
<span>每页 {{ PAGE_SIZE }} · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
<span class="flex flex-none items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="prevPage">
上一页
</NButton>
<NButton size="tiny" quaternary :disabled="!result.data.value?.nextStartWith" @click="nextPage">
下一页
</NButton>
</span>
@@ -848,7 +900,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
:title="parIsBucket ? '分享桶' : '分享对象'"
:width="460"
:submitting="parBusy"
:submit-disabled="!!parUrl || !parForm.expiresHours"
:submit-disabled="!!parUrl || !parExpiresValid"
submit-text="签发"
@update:show="showPar = $event"
@submit="doCreatePar"
@@ -862,27 +914,25 @@ const columns = computed<DataTableColumns<Row>>(() => [
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
</NRadioGroup>
</FormField>
<FormField label="有效期" hint="OCI 上限 30 天(720 小时)">
<FormField label="有效期" hint="最长 100 年(876000 小时">
<div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber
v-model:value="parForm.expiresHours"
:min="1"
:max="720"
:max="PAR_MAX_EXPIRES_HOURS"
:precision="0"
size="small"
class="!w-24"
/>
<span class="text-[12.5px] text-ink-2">小时</span>
<NButton
<ChoiceChip
v-for="q in QUICK_HOURS"
:key="q.value"
size="tiny"
quaternary
:type="parForm.expiresHours === q.value ? 'primary' : 'default'"
:active="parForm.expiresHours === q.value"
@click="parForm.expiresHours = q.value"
>
{{ q.label }}
</NButton>
</ChoiceChip>
</div>
</FormField>
<div
@@ -890,7 +940,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
>
<span class="mono min-w-0 flex-1 truncate text-xs">{{ parUrl }}</span>
<NButton size="tiny" @click="copyParUrl">复制</NButton>
<NButton size="tiny" quaternary @click="copyParUrl">复制</NButton>
</div>
<div v-if="parUrl" class="mt-1 text-xs text-warn">
链接仅本次展示,关闭后无法再次获取,请立即保存
@@ -115,8 +115,10 @@ watch(
return
}
reset()
const seq = ++contentSeq
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 = ''
}
// :/,
// A ETag B ,线
let contentSeq = 0
async function loadContent() {
if (!props.cfgId || loading.value) return
const seq = ++contentSeq
loading.value = true
loadError.value = ''
try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
if (seq !== contentSeq) return
contentEtag.value = c.etag
contentType.value = c.contentType
await applyContent(c.blob)
} catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败'
if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally {
loading.value = false
if (seq === contentSeq) loading.value = false
}
}
+4 -3
View File
@@ -35,7 +35,8 @@ const pars = useAsync<ParPage>(async () => {
const items = computed(() => pars.data.value?.items ?? [])
watch(
[() => props.cfgId, () => props.bucket],
// region :, PAR
[() => props.cfgId, () => props.region, () => props.bucket],
() => {
cursorStack.value = []
cursor.value = ''
@@ -199,8 +200,8 @@ const columns = computed<DataTableColumns<Par>>(() => [
>
<span>每页 {{ PAGE_SIZE }} </span>
<span class="flex items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
<NButton size="tiny" :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
<NButton size="tiny" quaternary :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
</span>
</div>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import ChoiceChip from '@/components/ChoiceChip.vue'
import { onBeforeUnmount, computed, ref, watch } from 'vue'
import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight'
@@ -60,22 +60,8 @@ async function renderMd() {
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1">
<NButton
size="tiny"
:quaternary="renderMode !== 'render'"
:type="renderMode === 'render' ? 'primary' : 'default'"
@click="renderMode = 'render'"
>
渲染
</NButton>
<NButton
size="tiny"
:quaternary="renderMode !== 'code'"
:type="renderMode === 'code' ? 'primary' : 'default'"
@click="renderMode = 'code'"
>
代码
</NButton>
<ChoiceChip :active="renderMode === 'render'" @click="renderMode = 'render'">渲染</ChoiceChip>
<ChoiceChip :active="renderMode === 'code'" @click="renderMode = 'code'">代码</ChoiceChip>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded bg-white">
<img
+1 -1
View File
@@ -127,7 +127,7 @@ async function save() {
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" quaternary @click="emit('update:show', false)">取消</NButton>
<NButton size="small" @click="emit('update:show', false)">取消</NButton>
</div>
</NModal>
</template>
+276 -121
View File
@@ -1,27 +1,37 @@
<script setup lang="ts">
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { discoverWallets, isUserRejected, personalSign, requestAccount } from '@/composables/useWallet'
import {
activateTotp,
beginPasskeyRegister,
disableTotp,
revokeSessions,
finishPasskeyRegister,
getCredentials,
getOAuthSettings,
getOauthAuthorizeUrl,
getTotpStatus,
getWalletChallenge,
listIdentities,
listPasskeys,
removePasskey,
setupTotp,
unbindIdentity,
updateCredentials,
updateOAuthSettings,
updatePasswordLogin,
verifyWallet,
} from '@/api/auth'
import ChoiceChip from '@/components/ChoiceChip.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FootNote from '@/components/FootNote.vue'
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
import { useAsync } from '@/composables/useAsync'
import { useMobileModal } from '@/composables/useMobileModal'
import { useAppStore } from '@/stores/app'
@@ -42,12 +52,17 @@ function applySession(r: SessionRefresh) {
if (r?.token) auth.setSession(r.token, r.expiresAt)
}
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
const providerLabels: Record<string, string> = {
github: 'GitHub',
oidc: 'OIDC 单点登录',
wallet: '钱包',
}
/** provider 的展示名:配置显示名 → 默认名 → 原串 */
/** provider 的展示名:配置显示名 → 默认名 → 原串;钱包等无自定义名,不得误读 GitHub 的 */
function displayNameOf(p: string): string {
const cfg = oauthCfg.data.value
const custom = p === 'oidc' ? cfg?.oidcDisplayName : cfg?.githubDisplayName
const custom =
p === 'oidc' ? cfg?.oidcDisplayName : p === 'github' ? cfg?.githubDisplayName : ''
return custom || providerLabels[p] || p
}
@@ -119,12 +134,86 @@ async function copyTotpSecret() {
}
}
// ---- (Passkey) ----
const passkeys = useAsync(listPasskeys)
const showPasskeyModal = ref(false)
const passkeyName = ref('')
const passkeyBusy = ref(false)
/** WebAuthn 仅在 secure context(HTTPS / localhost)可用;不支持时禁用入口并给出原因 */
const webauthnSupported = browserSupportsWebAuthn()
function openPasskeyModal() {
passkeyName.value = ''
showPasskeyModal.value = true
}
/** 两段式注册:后端 options → 浏览器验证器创建凭据 → 后端校验落库并换发会话 */
async function confirmAddPasskey() {
if (passkeyBusy.value) return // Enter :
passkeyBusy.value = true
try {
const { sessionId, options } = await beginPasskeyRegister()
const credential = await startRegistration({ optionsJSON: options.publicKey })
applySession(await finishPasskeyRegister(sessionId, passkeyName.value.trim(), credential))
message.success('通行密钥已添加,登录页可一键免密登录')
showPasskeyModal.value = false
void passkeys.run()
} catch (e) {
// (NotAllowedError)
if (!(e instanceof Error && e.name === 'NotAllowedError'))
message.error(e instanceof Error ? e.message : '添加失败')
} finally {
passkeyBusy.value = false
}
}
async function removePasskeyRow(id: number) {
try {
applySession(await removePasskey(id))
message.success('已删除;该通行密钥后续无法登录面板')
void passkeys.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
// ---- ----
const identities = useAsync(listIdentities)
const oauthCfg = useAsync(getOAuthSettings)
const binding = ref('')
// ---- :(EIP-4361), ----
const bindingWallet = ref(false)
async function bindWallet() {
bindingWallet.value = true
try {
const wallets = await discoverWallets()
if (!wallets.length) {
message.error('未检测到浏览器钱包扩展(MetaMask / OKX 等)')
return
}
// ;
const w = wallets[0]!
const address = await requestAccount(w.provider)
const { nonce, message: siwe } = await getWalletChallenge(address, 'bind')
const signature = await personalSign(w.provider, siwe, address)
applySession(await verifyWallet(nonce, signature))
message.success('钱包已绑定,可在登录页用签名登录')
void identities.run()
} catch (e) {
if (!isUserRejected(e)) message.error(e instanceof Error ? e.message : '绑定失败')
} finally {
bindingWallet.value = false
}
}
const hasIdentity = computed(() => (identities.data.value?.items ?? []).length > 0)
const hasPasskey = computed(() => (passkeys.data.value?.items ?? []).length > 0)
/** 禁用密码登录的门槛:任一免密方式(通行密钥或外部身份)即可;已禁用时始终可关闭 */
const canDisablePassword = computed(() => hasIdentity.value || hasPasskey.value)
/** 免密登录卡是否还没有任何条目(空态文案) */
const passwordlessEmpty = computed(() => !hasIdentity.value && !hasPasskey.value)
// ---- : / ----
const creds = useAsync(getCredentials)
@@ -185,12 +274,12 @@ async function saveCredentials() {
}
}
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */
/** 切换密码登录禁用;开启由后端校验至少一种免密方式(通行密钥或外部身份) */
async function togglePasswordLogin(disabled: boolean) {
togglingPwLogin.value = true
try {
applySession(await updatePasswordLogin(disabled))
message.success(disabled ? '已禁用密码登录,仅外部身份登录' : '已恢复密码登录')
message.success(disabled ? '已禁用密码登录,仅可用通行密钥或外部身份登录' : '已恢复密码登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
@@ -199,17 +288,7 @@ async function togglePasswordLogin(disabled: boolean) {
}
}
/** 已配置 clientID 的 provider 才显示绑定入口 */
const bindableProviders = computed(() => {
const cfg = oauthCfg.data.value
if (!cfg) return []
const out: string[] = []
if (cfg.oidcClientId) out.push('oidc')
if (cfg.githubClientId) out.push('github')
return out
})
/** 已绑定的 provider 集合;绑定入口按钮据此禁用,解绑后自动恢复 */
/** 已绑定的 provider 集合;表格「绑定」按钮据此置灰,解绑后自动恢复 */
const boundProviders = computed(
() => new Set((identities.data.value?.items ?? []).map((it) => it.provider)),
)
@@ -235,21 +314,6 @@ async function removeIdentity(id: number) {
}
}
// ---- :,() ----
const revoking = ref(false)
async function revokeAllSessions() {
revoking.value = true
try {
applySession(await revokeSessions())
message.success('已撤销全部会话;其他设备须重新登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
revoking.value = false
}
}
// ---- (OAuth provider): + ;, ----
type ProviderType = 'github' | 'oidc'
@@ -274,13 +338,20 @@ const pForm = reactive({
const providerRows = computed(() => {
const cfg = oauthCfg.data.value
if (!cfg) return []
const rows: { provider: ProviderType; name: string; clientId: string; disabled: boolean }[] = []
const rows: {
provider: ProviderType
name: string
clientId: string
disabled: boolean
ready: boolean
}[] = []
if (cfg.githubClientId)
rows.push({
provider: 'github',
name: displayNameOf('github'),
clientId: cfg.githubClientId,
disabled: cfg.githubDisabled,
ready: cfg.githubSecretSet,
})
if (cfg.oidcClientId)
rows.push({
@@ -288,6 +359,7 @@ const providerRows = computed(() => {
name: displayNameOf('oidc'),
clientId: cfg.oidcClientId,
disabled: cfg.oidcDisabled,
ready: cfg.oidcSecretSet && Boolean(cfg.oidcIssuer),
})
return rows
})
@@ -313,32 +385,20 @@ function openEditProvider(p: ProviderType) {
showProviderModal.value = true
}
const pFormValid = computed(
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
)
const secretConfigured = computed(() => {
const cfg = oauthCfg.data.value
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
})
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): 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,
}
}
const pFormValid = computed(
() =>
Boolean(pForm.clientId.trim()) &&
(pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())) &&
(secretConfigured.value || Boolean(pForm.secret)),
)
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
function providerFormPatch(): Partial<UpdateOAuthRequest> {
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
function providerFormPatch(): UpdateOAuthRequest {
const name = pForm.displayName.trim()
const id = pForm.clientId.trim()
if (pForm.provider === 'oidc')
@@ -358,7 +418,7 @@ function providerFormPatch(): Partial<UpdateOAuthRequest> {
async function saveProvider() {
savingOauth.value = true
try {
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
await updateOAuthSettings(providerFormPatch())
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
showProviderModal.value = false
void oauthCfg.run({ silent: true })
@@ -374,7 +434,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
savingOauth.value = true
try {
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
await updateOAuthSettings(oauthPayloadWith(patch))
await updateOAuthSettings(patch)
message.success(`${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
void oauthCfg.run({ silent: true })
} catch (e) {
@@ -393,7 +453,7 @@ async function deleteProvider(p: ProviderType) {
p === 'oidc'
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
await updateOAuthSettings(oauthPayloadWith(patch))
await updateOAuthSettings(patch)
message.success(`登录方式「${name}」已删除`)
void oauthCfg.run({ silent: true })
} catch (e) {
@@ -405,14 +465,15 @@ async function deleteProvider(p: ProviderType) {
</script>
<template>
<!-- 账号安全:两步验证 + 外部身份 -->
<!-- 密码登录 / 免密登录:双栏卡(窄屏纵排),方案B 布局 -->
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">账号安全</div>
<div class="mt-0.5 text-xs text-ink-3">两步验证与外部身份</div>
<div class="text-sm font-semibold">密码登录</div>
<div class="mt-0.5 text-xs text-ink-3">登录凭据与两步验证</div>
</div>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
@@ -427,11 +488,19 @@ async function deleteProvider(p: ProviderType) {
<div class="px-4.5 py-1">
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
<div class="text-[13px] font-medium">登录凭据</div>
<div class="mt-0.5 text-xs text-ink-3">
登录需额外输入验证器动态码
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
</div>
</div>
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">两步验证</div>
<div class="mt-0.5 text-xs text-ink-3">密码登录时额外输入验证器动态码</div>
</div>
<NSpin v-if="totp.loading.value" size="small" />
<NButton
v-else-if="totp.data.value?.enabled"
@@ -447,36 +516,14 @@ async function deleteProvider(p: ProviderType) {
</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">登录凭据</div>
<div class="mt-0.5 text-xs text-ink-3">
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
</div>
</div>
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">撤销全部会话</div>
<div class="mt-0.5 text-xs text-ink-3">
怀疑令牌泄露时使用;其他设备与旧令牌立即失效,本会话自动换新
</div>
</div>
<NButton size="tiny" type="error" quaternary :loading="revoking" @click="revokeAllSessions">
撤销
</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="flex items-center justify-between gap-3 py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">禁用密码登录</div>
<div class="mt-0.5 text-xs text-ink-3">
{{
hasIdentity || creds.data.value?.passwordLoginDisabled
? '开启后仅外部身份登录;关闭随时恢复密码登录'
: '至少绑定一个外部身份(OAuth2)后才能开启'
canDisablePassword || creds.data.value?.passwordLoginDisabled
? '开启后仅可用通行密钥或外部身份登录;关闭随时恢复'
: '至少添加一枚通行密钥或绑定一个外部身份'
}}
</div>
</div>
@@ -484,22 +531,109 @@ async function deleteProvider(p: ProviderType) {
:value="creds.data.value?.passwordLoginDisabled ?? false"
size="small"
:loading="togglingPwLogin"
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled"
:disabled="!canDisablePassword && !creds.data.value?.passwordLoginDisabled"
@update:value="togglePasswordLogin"
/>
</div>
</div>
</div>
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">免密登录</div>
<div class="mt-0.5 text-xs text-ink-3">通行密钥OAuth 与钱包;解绑受防自锁保护</div>
</div>
<div class="flex flex-none items-center gap-2">
<NButton
size="tiny"
type="primary"
:loading="passkeyBusy"
:disabled="!webauthnSupported"
@click="openPasskeyModal"
>
添加通行密钥
</NButton>
<NButton size="tiny" :loading="bindingWallet" @click="bindWallet">绑定钱包</NButton>
</div>
</div>
<div class="px-4.5 py-1">
<div
v-for="pk in passkeys.data.value?.items ?? []"
:key="'pk-' + pk.id"
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
>
<div class="flex min-w-0 items-center gap-2.5">
<span
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
>
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
<path d="M8.5 11a3.5 3.5 0 0 1 7 0c0 2.2-.2 4.2-.7 6" />
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
</svg>
</span>
<div class="min-w-0">
<div class="truncate text-[13px] font-medium">通行密钥 · {{ pk.name }}</div>
<div class="mt-0.5 text-xs text-ink-3">
添加于 {{ fmtTime(pk.createdAt) }}<template v-if="pk.lastUsedAt">
· 最近使用 {{ fmtTime(pk.lastUsedAt) }}</template>
</div>
</div>
</div>
<ConfirmPop
:title="`删除通行密钥「${pk.name}」?`"
content="删除后该密钥无法再登录面板;设备侧的凭据条目需自行清理。"
kind="plain"
confirm-text="删除"
:on-confirm="() => removePasskeyRow(pk.id)"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary>删除</NButton>
</template>
</ConfirmPop>
</div>
<div
v-for="it in identities.data.value?.items ?? []"
:key="it.id"
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
>
<div class="flex min-w-0 items-center gap-2.5">
<span
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg
v-if="it.provider === 'wallet'"
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
</svg>
<OauthProviderIcon v-else :provider="it.provider" class="!h-3.5 !w-3.5" />
</span>
<div class="min-w-0">
<div class="text-[13px] font-medium">
<div class="truncate text-[13px] font-medium">
{{ displayNameOf(it.provider) }} · {{ it.display }}
</div>
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
</div>
</div>
<ConfirmPop
:title="`解绑 ${displayNameOf(it.provider)} 身份?`"
content="解绑后该身份无法再登录面板。"
@@ -513,24 +647,15 @@ async function deleteProvider(p: ProviderType) {
</ConfirmPop>
</div>
<div
v-if="bindableProviders.length"
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5"
>
<NButton
v-for="p in bindableProviders"
:key="p"
size="small"
:loading="binding === p"
:disabled="boundProviders.has(p)"
@click="bindProvider(p)"
>
{{ boundProviders.has(p) ? '已绑定' : '绑定' }} {{ displayNameOf(p) }}
</NButton>
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
<div v-if="passwordlessEmpty" class="border-b border-line-soft py-2.5 text-xs text-ink-3">
尚未添加任何免密登录方式;添加后可在登录页免密码一键登录
</div>
<div v-if="!webauthnSupported" class="pt-2.5 text-xs text-warn">
当前环境不支持通行密钥:需通过 HTTPS( localhost)访问面板
</div>
<div class="py-2.5 text-xs text-ink-3">
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册)
OAuth 身份在下方登录方式配置操作列发起绑定;未绑定的身份无法登录(不开放注册)
</div>
</div>
</div>
</div>
@@ -542,7 +667,7 @@ async function deleteProvider(p: ProviderType) {
>
<div class="min-w-0">
<div class="text-sm font-semibold">登录方式配置</div>
<div class="mt-0.5 text-xs text-ink-3">配置后登录页与上方绑定入口即出现对应方式</div>
<div class="mt-0.5 text-xs text-ink-3">OAuth 提供方;启用后登录页出现入口,在操作列绑定到当前账号</div>
</div>
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
</div>
@@ -554,7 +679,7 @@ async function deleteProvider(p: ProviderType) {
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">Client ID</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">状态</th>
<th class="w-40 border-b border-line-soft px-3 py-2 font-medium">操作</th>
<th class="w-52 border-b border-line-soft px-3 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
@@ -570,13 +695,26 @@ async function deleteProvider(p: ProviderType) {
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<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'" />
{{ row.disabled ? '已禁用' : '启用中' }}
<span
class="h-1.5 w-1.5 rounded-full"
:class="!row.ready ? 'bg-warn' : row.disabled ? 'bg-ink-3' : 'bg-ok'"
/>
{{ !row.ready ? '配置不完整' : row.disabled ? '已禁用' : '启用中' }}
</span>
</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap">
<NButton
size="tiny"
quaternary
type="primary"
:loading="binding === row.provider"
:disabled="!row.ready || boundProviders.has(row.provider)"
@click="bindProvider(row.provider)"
>
绑定
</NButton>
<NButton size="tiny" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
<NButton
size="tiny"
@@ -605,7 +743,7 @@ async function deleteProvider(p: ProviderType) {
</div>
<FootNote>
IdP / GitHub 应用中填写的授权回调地址:
{{ callbackBase}}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback
{{ callbackBase }}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback;
</FootNote>
</div>
@@ -622,22 +760,15 @@ async function deleteProvider(p: ProviderType) {
>
<FormField label="类型" required>
<div class="flex flex-wrap gap-2">
<button
<ChoiceChip
v-for="t in PROVIDER_TYPES"
:key="t.value"
type="button"
class="rounded-md border px-3 py-1.5 text-xs"
:class="[
pForm.provider === t.value
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2',
editingProvider ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-ink-3',
]"
:active="pForm.provider === t.value"
:disabled="!!editingProvider"
@click="pForm.provider = t.value"
>
{{ t.label }}
</button>
</ChoiceChip>
</div>
</FormField>
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}」`">
@@ -668,6 +799,7 @@ async function deleteProvider(p: ProviderType) {
</FormField>
<FormField
label="Client Secret"
required
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
>
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
@@ -719,6 +851,29 @@ async function deleteProvider(p: ProviderType) {
</FormField>
</FormModal>
<!-- 添加通行密钥弹窗:命名后唤起浏览器验证器 -->
<FormModal
:show="showPasskeyModal"
title="添加通行密钥"
:width="380"
:submitting="passkeyBusy"
submit-text="继续"
@update:show="showPasskeyModal = $event"
@submit="confirmAddPasskey"
>
<FormField label="名称" hint="便于区分设备,如「MacBook Touch ID」;留空用默认名">
<NInput
v-model:value="passkeyName"
:maxlength="64"
placeholder="通行密钥"
@keyup.enter="confirmAddPasskey"
/>
</FormField>
<div class="text-xs text-ink-3">
点击继续后按浏览器提示完成验证(生物识别 / PIN / 安全钥匙)
</div>
</FormModal>
<!-- 停用两步验证弹窗 -->
<FormModal
:show="showTotpDisable"
@@ -0,0 +1,201 @@
<script setup lang="ts">
import { NButton, NSpin } from 'naive-ui'
import { computed, ref } from 'vue'
import { listSessions, revokeSession, revokeSessions } from '@/api/auth'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
import { fmtRelative, fmtTime, uaLabel } from '@/composables/useFormat'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
import type { SessionItem } from '@/types/api'
/** 活跃会话卡:全宽表格列出当前有效登录会话,支持定点撤销与撤销全部 */
const message = useToast()
const auth = useAuthStore()
const sessions = useAsync(listSessions)
const items = computed(() => sessions.data.value?.items ?? [])
/** :(/IP/);
* 零行不证明只有一个活跃会话升级前签发的旧令牌不产生记录 */
const empty = computed(() => items.value.length === 0)
const methodLabels: Record<string, string> = {
password: '密码',
passkey: '通行密钥',
wallet: '钱包',
oidc: 'OIDC',
github: 'GitHub',
}
function deviceOf(s: SessionItem) {
return uaLabel(s.userAgent)
}
/** :
* token ,并发的单撤销会携带旧 token 401 并触发全局登出竞态 */
const revoking = ref(false)
async function revokeOne(s: SessionItem) {
if (revoking.value) return
revoking.value = true
try {
await revokeSession(s.id)
message.success('已撤销,该设备将立即退出登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '撤销失败')
} finally {
revoking.value = false
void sessions.run({ silent: true })
}
}
/** 撤销全部 = 令牌版本递增:其他设备立即失效,本会话经新 token 无感换新 */
async function revokeAll() {
if (revoking.value) return
revoking.value = true
try {
const r = await revokeSessions()
if (r?.token) auth.setSession(r.token, r.expiresAt)
message.success('已撤销全部会话;其他设备须重新登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
revoking.value = false
void sessions.run({ silent: true })
}
}
</script>
<template>
<div class="panel">
<div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
>
<div class="min-w-0">
<div class="text-sm font-semibold">活跃会话</div>
<div class="mt-0.5 text-xs text-ink-3">
当前有效登录会话,最近活跃在前;登出撤销或凭据变更后自动消失
</div>
</div>
<ConfirmPop
title="撤销全部会话?"
content="其他设备与旧令牌立即失效并需重新登录;本会话自动换新。"
confirm-text="全部撤销"
:on-confirm="revokeAll"
>
<template #trigger>
<!-- 不依赖列表禁用(存量令牌唯一失效入口);仅与单撤销互斥 -->
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销全部会话</NButton>
</template>
</ConfirmPop>
</div>
<div v-if="sessions.loading.value && !sessions.data.value" class="flex justify-center py-8">
<NSpin size="small" />
</div>
<!-- 请求失败与空列表分开呈现,失败不得伪装成仅本机一个会话 -->
<div
v-else-if="sessions.error.value"
class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3"
>
会话列表加载失败:{{ sessions.error.value }}
<NButton size="tiny" quaternary type="primary" class="ml-1" @click="() => sessions.run()">
重试
</NButton>
</div>
<div v-else-if="empty" class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3">
暂无会话记录升级前签发的旧令牌不产生记录,不代表没有其他活跃会话;
如有顾虑可直接撤销全部会话
</div>
<div v-else class="overflow-x-auto">
<table class="w-full text-[12.5px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="border-b border-line-soft px-4.5 py-2 font-medium">设备</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">登录方式</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">登录时间</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">最近活跃</th>
<th class="w-20 border-b border-line-soft px-3 py-2 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in items" :key="s.id" class="[&:last-child>td]:border-b-0">
<td class="border-b border-line-soft px-4.5 py-2.5">
<div class="flex items-center gap-2.5">
<span
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
>
<svg
v-if="deviceOf(s).mobile"
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
>
<rect x="7" y="2" width="10" height="20" rx="2" />
<path d="M11 18h2" />
</svg>
<svg
v-else
class="h-3.5 w-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
>
<rect x="3" y="5" width="18" height="12" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
</span>
<span class="font-medium whitespace-nowrap">{{ deviceOf(s).label }}</span>
<span
v-if="s.current"
class="rounded-full bg-ok/12 px-2 py-px text-[11px] font-semibold whitespace-nowrap text-ok"
>
本机
</span>
</div>
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span
class="rounded-full border border-line bg-white px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
>
{{ methodLabels[s.method] || s.method || '—' }}
</span>
</td>
<td class="mono border-b border-line-soft px-3 py-2.5 text-ink-2">{{ s.clientIp }}</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
{{ fmtTime(s.createdAt) }}
</td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
{{ fmtRelative(s.lastSeenAt) }}
</td>
<td class="border-b border-line-soft px-3 py-2.5">
<span v-if="s.current" class="px-2 text-xs text-ink-3"></span>
<ConfirmPop
v-else
title="撤销该会话?"
:content="`${deviceOf(s).label}(${s.clientIp})将立即退出登录;其他会话不受影响。`"
confirm-text="撤销"
:on-confirm="() => revokeOne(s)"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销</NButton>
</template>
</ConfirmPop>
</td>
</tr>
</tbody>
</table>
</div>
<FootNote>
当前会话不可定点撤销(请用退出登录);升级前签发的存量令牌不在列表中,24h 内自然过期
</FootNote>
</div>
</template>
@@ -191,7 +191,6 @@ async function sendTest() {
:key="p.label"
size="tiny"
quaternary
type="primary"
@click="form.bodyTemplate = p.tpl"
>
{{ p.label }}
+5 -5
View File
@@ -9,7 +9,7 @@ import { cronText, parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/c
import { fmtRelative } from '@/composables/useFormat'
import type { Task } from '@/types/api'
const props = defineProps<{ task: Task; cfgAlias?: string }>()
const props = defineProps<{ task: Task; cfgAlias?: string; busy?: boolean }>()
const emit = defineEmits<{ run: []; toggle: []; edit: []; remove: [] }>()
const router = useRouter()
@@ -79,7 +79,7 @@ function goDetail() {
<div class="flex flex-none items-center gap-0.5" @click.stop>
<NTooltip v-if="task.status !== 'succeeded'">
<template #trigger>
<NButton size="small" quaternary circle @click="emit('toggle')">
<NButton size="small" quaternary :disabled="busy" @click="emit('toggle')">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
@@ -98,7 +98,7 @@ function goDetail() {
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="small" quaternary circle @click="emit('run')">
<NButton size="small" quaternary :disabled="busy" @click="emit('run')">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
@@ -116,7 +116,7 @@ function goDetail() {
</NTooltip>
<NTooltip>
<template #trigger>
<NButton size="small" quaternary circle @click="goDetail">
<NButton size="small" quaternary @click="goDetail">
<svg
class="h-[15px] w-[15px]"
viewBox="0 0 24 24"
@@ -132,7 +132,7 @@ function goDetail() {
详情与事件流
</NTooltip>
<NDropdown v-if="moreOptions.length" :options="moreOptions" trigger="click" @select="onMore">
<NButton size="small" quaternary circle>
<NButton size="small" quaternary>
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
+3 -1
View File
@@ -70,7 +70,9 @@ watch(
if (t.type === 'snatch') {
const inst = (p.instance ?? {}) as Record<string, unknown>
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
form.count = typeof p.count === 'number' ? p.count : 1
// : totalCount,退
const target = typeof p.totalCount === 'number' ? p.totalCount : p.count
form.count = typeof target === 'number' ? target : 1
form.displayName = String(inst.displayName ?? '')
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
+253
View File
@@ -0,0 +1,253 @@
<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]; switched: [] }>()
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 || !props.user || activating.value) return
const switched = !props.user.isCurrentUser
activating.value = true
try {
await activateApiKey(props.cfgId, created.value.fingerprint, created.value.privateKey, props.user.id)
activated.value = true
toast.success(switched ? `面板签名已切换到用户 ${props.user.name}` : '面板签名凭据已切换到该 key')
if (switched) emit('switched')
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 class="mt-3 flex">
<NButton
v-if="user?.isCurrentUser"
size="tiny"
quaternary
type="primary"
:loading="activating"
:disabled="activated"
@click="doActivate"
>
{{ activated ? '已设为面板签名 key' : '设为面板签名 key' }}
</NButton>
<ConfirmPop
v-else
title="替换面板签名凭据?"
:content="`面板将改用此 key 签名。`"
note="权限随该用户变化"
kind="warn"
confirm-text="替换"
:on-confirm="doActivate"
>
<template #trigger>
<NButton size="tiny" quaternary type="primary" :loading="activating" :disabled="activated">
{{ activated ? '已切换面板签名到该用户' : '替换面板签名凭据' }}
</NButton>
</template>
</ConfirmPop>
</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" quaternary 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) {
const seq = ++rawSeq
detailRaw.value = null
rawGone.value = false
if (!row.eventId || !row.eventTime) {
@@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
eventId: row.eventId,
eventTime: row.eventTime,
})
detailRaw.value = raw
if (seq === rawSeq) detailRaw.value = raw
} catch {
rawGone.value = true
if (seq === rawSeq) rawGone.value = true
} finally {
rawLoading.value = false
if (seq === rawSeq) rawLoading.value = false
}
}
+157 -21
View File
@@ -1,12 +1,19 @@
<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 { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
import {
createIdp,
deleteIdpIcon,
getIdentitySetting,
uploadIdpIcon,
type CreateIdpBody,
} from '@/api/tenant'
import FilePicker from '@/components/FilePicker.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
import type { IdpIconUpload } from '@/types/api'
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
@@ -15,6 +22,7 @@ const message = useToast()
const submitting = ref(false)
// JIT
const primaryEmailRequired = ref(false)
const identitySettingReady = ref(false)
const nameIdFormatOptions = [
{ label: '无', value: 'saml-none' },
@@ -51,22 +59,66 @@ const 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(
() => props.show,
(v) => {
if (!v) return
() => form.iconUrl,
() => (iconError.value = false),
)
// :,
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)
void loadIdentitySetting()
},
}
watch(
() => [props.show, props.cfgId, props.domainId] as const,
([show]) => (show ? resetForm() : abandonForm()),
{ immediate: true },
)
// show/cfgId/domainId abandonForm ,
async function loadIdentitySetting() {
const seq = ++identitySettingSeq
identitySettingReady.value = false
try {
const s = await getIdentitySetting(props.cfgId)
const s = await getIdentitySetting(props.cfgId, props.domainId)
if (seq !== identitySettingSeq) return
primaryEmailRequired.value = s.primaryEmailRequired
if (s.primaryEmailRequired) form.jitMapEmail = true
} 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)
})
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(
() =>
!!form.name.trim() &&
form.metadata.includes('EntityDescriptor') &&
iconUrlValid.value &&
!iconUploading.value &&
identitySettingReady.value &&
!submitting.value &&
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
)
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
}
async function submit() {
if (submitting.value || !canSubmit.value) return
const body = buildBody()
submitting.value = true
try {
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
emit('update:show', false)
const created = await createIdp(props.cfgId, body, props.domainId)
// , IdP ,
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')
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
@@ -124,12 +232,17 @@ async function submit() {
:submitting="submitting"
:submit-disabled="!canSubmit"
submit-text="创建(禁用态)"
@update:show="emit('update:show', $event)"
@update:show="onModalShowChange"
@submit="submit"
>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="名称" required>
<NInput v-model:value="form.name" placeholder="my-idp" />
</FormField>
<FormField label="备注">
<NInput v-model:value="form.description" placeholder="可选" />
</FormField>
</div>
<FormField
label="IdP SAML metadata XML 文件"
required
@@ -153,26 +266,49 @@ async function submit() {
@load="(text) => (form.metadata = text)"
/>
</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
label="Logo(登录页图标)"
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
>
<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
v-if="form.iconUrl && iconUrlValid"
v-if="form.iconUrl && iconUrlValid && !iconError"
:src="form.iconUrl"
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>
</FormField>
</div>
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
映射用户身份
+66 -32
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { NButton, NDataTable, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { NButton, NDataTable, NSwitch, type DataTableColumns } from 'naive-ui'
import { computed, h, ref } from 'vue'
import {
activateIdp,
@@ -22,10 +22,15 @@ import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useToast()
const dialog = useDialog()
const idps = useAsync(() => listIdps(props.cfgId, props.domainId))
const rules = useAsync(() => listSignOnRules(props.cfgId, props.domainId))
const showCreate = ref(false)
const downloading = ref(false)
const exempting = ref(false)
/** 正在切换启用状态的 IdP id 集合,开关按行 loading 防连拨 */
const toggling = ref(new Set<string>())
const enabledIdp = computed(() => idps.data.value?.find((i) => i.enabled))
async function act(fn: () => Promise<unknown>, ok: string) {
try {
@@ -38,6 +43,16 @@ async function act(fn: () => Promise<unknown>, ok: string) {
}
}
async function toggleIdp(row: IdentityProvider, v: boolean) {
if (toggling.value.has(row.id)) return
toggling.value.add(row.id)
try {
await act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用')
} finally {
toggling.value.delete(row.id)
}
}
const idpColumns: DataTableColumns<IdentityProvider> = [
{
title: 'IdP',
@@ -75,13 +90,24 @@ const idpColumns: DataTableColumns<IdentityProvider> = [
h(NSwitch, {
size: 'small',
value: row.enabled,
onUpdateValue: (v: boolean) =>
act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用'),
loading: toggling.value.has(row.id),
disabled: toggling.value.has(row.id),
onUpdateValue: (v: boolean) => toggleIdp(row, v),
}),
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteIdp(row) },
{ default: () => '删除' },
ConfirmPop,
{
title: `删除 IdP「${row.name}」?`,
content: '此操作不可恢复。',
note: '将自动清理关联的免 MFA 规则,并从登录页移除',
confirmText: '删除',
onConfirm: () =>
act(() => deleteIdp(props.cfgId, row.id, props.domainId), `已删除 IdP「${row.name}」及其关联规则`),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
},
),
]),
},
@@ -139,39 +165,30 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
]
async function doDownloadMetadata() {
if (downloading.value) return
downloading.value = true
try {
await downloadSamlMetadata(props.cfgId, props.domainId)
message.success('SAML 元数据已下载')
} catch (e) {
message.error(e instanceof Error ? e.message : '下载失败')
} finally {
downloading.value = false
}
}
function confirmDeleteIdp(row: IdentityProvider) {
dialog.warning({
title: '删除身份提供商',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除 IdP「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '将自动清理关联的免 MFA 规则,并从登录页移除'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () =>
act(() => deleteIdp(props.cfgId, row.id, props.domainId), `已删除 IdP「${row.name}」及其关联规则`),
})
}
function addExemption() {
const enabled = idps.data.value?.find((i) => i.enabled)
if (!enabled) {
message.warning('没有已启用的 IdP')
return
}
void act(
async function addExemption() {
const enabled = enabledIdp.value
if (!enabled || exempting.value) return
exempting.value = true
try {
await act(
() => createSignOnExemption(props.cfgId, enabled.id, props.domainId),
`已为 ${enabled.name} 创建免 MFA 规则并置顶`,
)
} finally {
exempting.value = false
}
}
</script>
@@ -181,7 +198,7 @@ function addExemption() {
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">SAML 身份提供商</div>
<div class="flex items-center gap-2">
<NButton size="small" @click="doDownloadMetadata">下载 SP 元数据</NButton>
<NButton size="small" :loading="downloading" @click="doDownloadMetadata">下载 SP 元数据</NButton>
<NButton size="small" type="primary" @click="showCreate = true">创建 IdP</NButton>
</div>
</div>
@@ -200,7 +217,24 @@ function addExemption() {
<div class="panel">
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">Sign-on 策略 MFA 规则</div>
<NButton size="small" @click="addExemption">为已启用 IdP 创建免 MFA 规则</NButton>
<ConfirmPop
title="创建免 MFA 规则?"
:content="`经「${enabledIdp?.name}」登录的用户将跳过 MFA,规则置顶生效。`"
kind="warn"
confirm-text="创建"
:on-confirm="addExemption"
>
<template #trigger>
<NButton
size="small"
:loading="exempting"
:disabled="!enabledIdp"
:title="enabledIdp ? undefined : '没有已启用的 IdP'"
>
为已启用 IdP 创建免 MFA 规则
</NButton>
</template>
</ConfirmPop>
</div>
<NDataTable
:columns="ruleColumns"
+1 -1
View File
@@ -283,7 +283,7 @@ function policyDesc(p: PasswordPolicy): string {
<span class="min-w-0 flex-1 text-xs text-ink-3">
推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成
</span>
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook">
<NButton size="tiny" quaternary :loading="savingWebhook" @click="createWebhook">
生成回调地址
</NButton>
</div>
+38 -33
View File
@@ -2,9 +2,10 @@
import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui'
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 StatusBadge from '@/components/StatusBadge.vue'
import ApiKeysModal from '@/components/tenant/ApiKeysModal.vue'
import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
@@ -19,6 +20,8 @@ const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId, props.domainId))
const showForm = ref(false)
const editingUser = ref<IamUser | null>(null)
const showKeys = ref(false)
const keysUser = ref<IamUser | null>(null)
function openCreate() {
editingUser.value = null
@@ -30,6 +33,11 @@ function openEdit(row: IamUser) {
showForm.value = true
}
function openKeys(row: IamUser) {
keysUser.value = row
showKeys.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) {
try {
await fn()
@@ -40,20 +48,6 @@ async function act(fn: () => Promise<unknown>, ok: string) {
}
}
function confirmDelete(row: IamUser) {
dialog.warning({
title: '删除用户',
content: () =>
h('div', { class: 'text-[13px]' }, [
h('p', `确定删除用户「${row.name}」?此操作不可恢复。`),
h('p', { class: 'mt-1.5 text-ink-3' }, '删除后该用户的全部 API Key 将一并吊销'),
]),
positiveText: '确认删除',
negativeText: '取消',
onPositiveClick: () => act(() => deleteUser(props.cfgId, row.id, props.domainId), `已删除用户 ${row.name}`),
})
}
async function resetPwd(row: IamUser) {
try {
const { password } = await resetUserPassword(props.cfgId, row.id, props.domainId)
@@ -112,11 +106,23 @@ const columns: DataTableColumns<IamUser> = [
{
title: '操作',
key: 'actions',
width: 290,
width: 300,
render: (row) =>
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: () => resetPwd(row) }, { default: () => '重置密码' }),
h(
ConfirmPop,
{
title: '重置密码?',
content: `将为 ${row.name} 生成一次性新密码。`,
kind: 'warn',
confirmText: '重置',
onConfirm: () => resetPwd(row),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '重置密码' }),
},
),
h(
ConfirmPop,
{
@@ -131,25 +137,22 @@ const columns: DataTableColumns<IamUser> = [
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
},
),
h(
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' }),
},
),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openKeys(row) }, { default: () => 'API Keys' }),
row.isCurrentUser
? null
: h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) },
{ default: () => '删除' },
ConfirmPop,
{
title: `删除用户「${row.name}」?`,
content: '此操作不可恢复。',
note: '删除后该用户的全部 API Key 将一并吊销',
confirmText: '删除',
onConfirm: () => act(() => deleteUser(props.cfgId, row.id, props.domainId), `已删除用户 ${row.name}`),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
},
),
]),
},
@@ -180,5 +183,7 @@ const columns: DataTableColumns<IamUser> = [
:user="editingUser"
@created="users.run()"
/>
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" @switched="users.run()" />
</div>
</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()
})
})
+26
View File
@@ -20,6 +20,32 @@ export function fmtRelative(iso: string | null | undefined): string {
return `${Math.floor(hr / 24)} 天前`
}
/** UserAgent → 「Chrome · macOS」式设备标签;识别不出的部分省略 */
export function uaLabel(ua: string): { label: string; mobile: boolean } {
const label = [uaBrowser(ua), uaOS(ua)].filter(Boolean).join(' · ') || '未知设备'
return { label, mobile: /Mobile|iPhone|Android/i.test(ua) }
}
// 顺序敏感:Edge/Opera 的 UA 含 Chrome,Chrome 的 UA 含 Safari
function uaBrowser(ua: string): string {
if (/Edg(e|A|iOS)?\//.test(ua)) return 'Edge'
if (/OPR\/|Opera/.test(ua)) return 'Opera'
if (/Firefox\/|FxiOS\//.test(ua)) return 'Firefox'
if (/Chrome\/|CriOS\//.test(ua)) return 'Chrome'
if (/Safari\//.test(ua)) return 'Safari'
return ''
}
function uaOS(ua: string): string {
if (/iPad/.test(ua)) return 'iPad'
if (/iPhone/.test(ua)) return 'iPhone'
if (/Android/.test(ua)) return 'Android'
if (/Windows/.test(ua)) return 'Windows'
if (/Mac OS X|Macintosh/.test(ua)) return 'macOS'
if (/Linux/.test(ua)) return 'Linux'
return ''
}
export function fmtBytes(n: number): string {
if (n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
+79
View File
@@ -0,0 +1,79 @@
/**
* (EIP-1193)接入:EIP-6963 + personal_sign
* SDK;WalletConnect
*/
/** EIP-1193 provider 最小接口 */
export interface Eip1193Provider {
request(args: { method: string; params?: unknown[] }): Promise<unknown>
}
/** EIP-6963 announce 事件携带的钱包信息 */
export interface WalletDetail {
info: { uuid: string; name: string; icon: string; rdns: string }
provider: Eip1193Provider
}
interface Eip6963AnnounceEvent extends Event {
detail: WalletDetail
}
/** 兼容旧式单一注入的 window.ethereum */
declare global {
interface Window {
ethereum?: Eip1193Provider
}
}
/**
* 发现已安装的注入钱包:广播 EIP-6963 announce;
* 退 window.ethereum()
*/
export function discoverWallets(collectMs = 300): Promise<WalletDetail[]> {
return new Promise((resolve) => {
const found: WalletDetail[] = []
const onAnnounce = (e: Event) => {
const detail = (e as Eip6963AnnounceEvent).detail
if (detail?.provider && !found.some((w) => w.info.uuid === detail.info.uuid)) found.push(detail)
}
window.addEventListener('eip6963:announceProvider', onAnnounce)
window.dispatchEvent(new Event('eip6963:requestProvider'))
setTimeout(() => {
window.removeEventListener('eip6963:announceProvider', onAnnounce)
if (!found.length && window.ethereum) {
found.push({
info: { uuid: 'legacy', name: '浏览器钱包', icon: '', rdns: 'legacy.injected' },
provider: window.ethereum,
})
}
resolve(found)
}, collectMs)
})
}
/** 连接钱包并返回首个授权账户地址;用户拒绝时抛出钱包侧错误 */
export async function requestAccount(provider: Eip1193Provider): Promise<string> {
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[]
const addr = accounts?.[0]
if (!addr) throw new Error('钱包未返回账户地址')
return addr
}
/** 对消息做 personal_sign(EIP-191);返回 0x 前缀的 65 字节签名 hex */
export async function personalSign(
provider: Eip1193Provider,
message: string,
address: string,
): Promise<string> {
const sig = (await provider.request({
method: 'personal_sign',
params: [message, address],
})) as string
if (!sig || typeof sig !== 'string') throw new Error('钱包未返回签名')
return sig
}
/** 钱包侧用户拒绝(EIP-1193 code 4001);拒绝不作为错误提示 */
export function isUserRejected(e: unknown): boolean {
return typeof e === 'object' && e !== null && (e as { code?: number }).code === 4001
}
+2 -2
View File
@@ -100,8 +100,8 @@ const router = createRouter({
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed) return { name: 'overview' }
if (!to.meta.public && !auth.isAuthed()) return { name: 'login', query: { redirect: to.fullPath } }
if (to.name === 'login' && auth.isAuthed()) return { name: 'overview' }
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 { computed, ref } from 'vue'
import { ref } from 'vue'
const TOKEN_KEY = 'oci-portal.token'
const EXPIRES_KEY = 'oci-portal.expiresAt'
@@ -8,11 +8,13 @@ export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
const isAuthed = computed(() => {
// 普通函数而非 computed:computed 只随 token/expiresAt 失效,时间流逝不会
// 触发重算,令牌自然过期后路由守卫会长期误判仍已登录
function isAuthed(): boolean {
if (!token.value) return false
if (!expiresAt.value) return true
return new Date(expiresAt.value).getTime() > Date.now()
})
}
function setSession(newToken: string, newExpiresAt: string) {
token.value = newToken
+4 -1
View File
@@ -9,9 +9,12 @@ function makeOverrides(t: ThemeTokens): GlobalThemeOverrides {
common: {
primaryColor: t.accent,
primaryColorHover: t.accentHover,
primaryColorPressed: t.accentHover,
primaryColorPressed: t.accentPressed,
primaryColorSuppl: t.accent,
errorColor: t.err,
errorColorHover: t.errHover,
errorColorPressed: t.errPressed,
errorColorSuppl: t.err,
successColor: t.ok,
infoColor: t.info,
warningColor: t.warn,
+6
View File
@@ -16,9 +16,12 @@ export const tokens = {
washDeep: '#ECE9DF',
accent: '#C96442',
accentHover: '#AD4F2E',
accentPressed: '#9A4527',
chart: '#C15F3C',
ok: '#788C5D',
err: '#B53333',
errHover: '#9C2B2B',
errPressed: '#872525',
info: '#6A9BCC',
warn: '#A87514',
fontSans:
@@ -45,9 +48,12 @@ export const darkTokens: ThemeTokens = {
washDeep: '#33312D',
accent: '#D97757',
accentHover: '#E08B6D',
accentPressed: '#C4674A',
chart: '#D97757',
ok: '#8FA574',
err: '#D06060',
errHover: '#D97070',
errPressed: '#B85454',
info: '#7FABD6',
warn: '#C29135',
}
+73 -8
View File
@@ -66,17 +66,17 @@ export interface OAuthSettings {
githubDisabled: boolean
}
/** 保存 provider 配置;secret 缺省沿用、空串清除 */
/** provider 配置字段补丁:缺省字段沿用现值;secret 空串清除、缺省沿用 */
export interface UpdateOAuthRequest {
oidcIssuer: string
oidcClientId: string
oidcIssuer?: string
oidcClientId?: string
oidcClientSecret?: string
oidcDisplayName: string
oidcDisabled: boolean
githubClientId: string
oidcDisplayName?: string
oidcDisabled?: boolean
githubClientId?: string
githubClientSecret?: string
githubDisplayName: string
githubDisabled: boolean
githubDisplayName?: string
githubDisabled?: boolean
}
/** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */
@@ -85,6 +85,33 @@ export interface OauthProviderInfo {
displayName: string
}
/** 已注册的通行密钥(Passkey)凭据 */
export interface PasskeyInfo {
id: number
name: string
createdAt: string
lastUsedAt: string | null
}
/** WebAuthn 仪式发起响应;options 交给浏览器 API,sessionId 原样带回 finish */
export interface PasskeyCeremonyOptions<T> {
sessionId: string
options: { publicKey: T }
}
/** 活跃登录会话;current 标记请求者自身会话(不可定点撤销) */
export interface SessionItem {
id: number
/** 登录方式:password / oidc / github / passkey / wallet */
method: string
clientIp: string
userAgent: string
createdAt: string
lastSeenAt: string
expiresAt: string
current: boolean
}
// ---- API Key 配置(租户)----
export type AccountType = 'paid' | 'trial' | 'free' | 'unknown'
export type AliveStatus = 'alive' | 'dead' | 'suspended' | 'unknown'
@@ -639,6 +666,21 @@ export interface IamUser {
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 用户
export interface IamUserDetail {
inDomain: boolean
@@ -699,6 +741,13 @@ export interface IdentitySetting {
}
// ---- Federation ----
export interface IdpSetupWarning {
code: 'JIT_SETUP_INCOMPLETE'
message: string
resourceCreated: true
requestId: string
}
export interface IdentityProvider {
id: string
name: string
@@ -707,6 +756,14 @@ export interface IdentityProvider {
partnerProviderId: string
jitEnabled: boolean
timeCreated: string
/** 资源曾创建且回滚状态不确定;创建方必须按资源可能存在处理 */
setupWarning?: IdpSetupWarning
}
/** IdP 图标上传结果;fileName 为身份域存储内标识,留作后续清理 */
export interface IdpIconUpload {
url: string
fileName: string
}
export interface SignOnRule {
@@ -756,6 +813,14 @@ export interface OverviewCost {
currency: string
total: number
days: OverviewCostDay[]
/** 按币种拆分的序列(合计降序);顶层 currency/total/days 恒为首个主币种,跨币种不相加 */
series: OverviewCostSeries[]
}
export interface OverviewCostSeries {
currency: string
total: number
days: OverviewCostDay[]
}
export interface OverviewTasks {
+79 -19
View File
@@ -23,6 +23,8 @@ import {
listConsoleConnections,
listVolumeAttachments,
terminateInstance,
terminatingInstances,
terminatingKey,
updateInstance,
} from '@/api/instances'
import EmptyCard from '@/components/EmptyCard.vue'
@@ -226,11 +228,20 @@ async function act(fn: () => Promise<unknown>, ok: string) {
}
}
function power(action: PowerAction) {
void act(
/** 电源操作在飞行中的动作,对应按钮 loading、整组防连点 */
const powering = ref<'' | PowerAction>('')
async function power(action: PowerAction) {
if (powering.value || terminating.value) return
powering.value = action
try {
await act(
() => instanceAction(cfgId.value, instanceId.value, action, region.value),
`${action} 已提交`,
)
} finally {
powering.value = ''
}
}
const moreOptions = [
@@ -238,22 +249,35 @@ const moreOptions = [
{ label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' },
]
/** 终止飞行中(api 层共享锁):跨 Dialog/视图/重挂载互斥 */
const terminating = computed(() =>
terminatingInstances.has(terminatingKey(cfgId.value, instanceId.value)),
)
/** 首次点击即锁定两个互斥分支,防慢请求期间「删除/保留引导卷」并发提交 */
function terminate() {
dialog.warning({
if (terminating.value) {
message.warning('终止请求正在处理中')
return
}
let fired = false
const submit = (preserve: boolean) => {
if (fired || terminating.value) return false
fired = true
d.positiveButtonProps = { disabled: true }
d.negativeButtonProps = { disabled: true }
return act(
() => terminateInstance(cfgId.value, instanceId.value, preserve, region.value),
preserve ? '终止请求已提交(保留引导卷)' : '终止请求已提交',
)
}
const d = dialog.warning({
title: '终止实例',
content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷',
onPositiveClick: () =>
act(
() => terminateInstance(cfgId.value, instanceId.value, false, region.value),
'终止请求已提交',
),
onNegativeClick: () =>
act(
() => terminateInstance(cfgId.value, instanceId.value, true, region.value),
'终止请求已提交(保留引导卷)',
),
onPositiveClick: () => submit(false),
onNegativeClick: () => submit(true),
})
}
@@ -426,20 +450,56 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
<div class="flex-1" />
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
<div class="flex items-center gap-2 max-md:w-full">
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
<NButton
v-if="isStopped"
size="small"
:loading="powering === 'START'"
:disabled="isEnded || !!powering || terminating"
@click="power('START')"
>
启动
</NButton>
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
<ConfirmPop
v-else
title="停止实例?"
content="实例将关机,其上服务与连接中断。"
kind="warn"
confirm-text="停止"
:on-confirm="() => power('STOP')"
>
<template #trigger>
<NButton size="small" :loading="powering === 'STOP'" :disabled="isEnded || !!powering || terminating">
停止
</NButton>
</template>
</ConfirmPop>
<ConfirmPop
title="重启实例?"
content="实例将立即重启,期间服务中断。"
kind="warn"
confirm-text="重启"
:on-confirm="() => power('RESET')"
>
<template #trigger>
<NButton size="small" :loading="powering === 'RESET'" :disabled="isEnded || !!powering || terminating">重启</NButton>
</template>
</ConfirmPop>
<NButton
size="small"
type="error"
ghost
:loading="terminating"
:disabled="isEnded || !!powering || terminating"
@click="terminate"
>
终止
</NButton>
<NDropdown
:options="moreOptions"
:disabled="isEnded"
:disabled="isEnded || !!powering || terminating"
@select="(key: string) => power(key as PowerAction)"
>
<NButton size="small" quaternary :disabled="isEnded"></NButton>
<NButton size="small" quaternary :disabled="isEnded || !!powering || terminating"></NButton>
</NDropdown>
</div>
</div>
+100 -26
View File
@@ -11,8 +11,15 @@ import {
import { computed, h, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { instanceAction, listInstances, terminateInstance } from '@/api/instances'
import {
instanceAction,
listInstances,
terminateInstance,
terminatingInstances,
terminatingKey,
} from '@/api/instances'
import CompartmentSelect from '@/components/CompartmentSelect.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
@@ -83,27 +90,61 @@ function isEnded(row: InstanceRow): boolean {
return row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING'
}
/** 行内电源操作在飞行中:实例 id → 动作;对应按钮 loading,整行防连点 */
const powering = ref(new Map<string, PowerAction>())
async function power(row: InstanceRow, action: PowerAction) {
if (powering.value.has(row.id)) return
powering.value.set(row.id, action)
try {
await instanceAction(row.cfgId, row.id, action, scope.region || undefined)
message.success(`${action} 已提交:${row.displayName}`)
void rows.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
powering.value.delete(row.id)
}
}
/** 电源按钮统一 loading / 禁用判定;终止飞行中电源操作一并禁用 */
function powerBtnProps(row: InstanceRow, action: PowerAction) {
return {
size: 'tiny' as const,
quaternary: true,
loading: powering.value.get(row.id) === action,
disabled:
isEnded(row) ||
powering.value.has(row.id) ||
terminatingInstances.has(terminatingKey(row.cfgId, row.id)),
}
}
const dialog = useDialog()
/** 与实例详情页一致:双选是否保留引导卷,返回 Promise 让 dialog 按钮 loading */
/** :, Promise dialog loading;
* 首次点击即锁定两个互斥分支; Dialog/视图/重挂载的互斥由 api
* terminatingInstances 共享锁承担 */
function confirmTerminate(row: InstanceRow) {
dialog.warning({
if (terminatingInstances.has(terminatingKey(row.cfgId, row.id))) {
message.warning('该实例的终止请求正在处理中')
return
}
let fired = false
const submit = (preserve: boolean) => {
if (fired || terminatingInstances.has(terminatingKey(row.cfgId, row.id))) return false
fired = true
d.positiveButtonProps = { disabled: true }
d.negativeButtonProps = { disabled: true }
return doTerminate(row, preserve)
}
const d = dialog.warning({
title: `终止实例 ${row.displayName}`,
content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷',
onPositiveClick: () => doTerminate(row, false),
onNegativeClick: () => doTerminate(row, true),
onPositiveClick: () => submit(false),
onNegativeClick: () => submit(true),
})
}
@@ -177,22 +218,40 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
row.lifecycleState === 'STOPPED'
? h(
NButton,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'START') },
{ ...powerBtnProps(row, 'START'), onClick: () => power(row, 'START') },
{ default: () => '启动' },
)
: h(
NButton,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'STOP') },
{ default: () => '停止' },
ConfirmPop,
{
title: `停止实例「${row.displayName}」?`,
content: '实例将关机,其上服务与连接中断。',
kind: 'warn',
confirmText: '停止',
onConfirm: () => power(row, 'STOP'),
},
{ trigger: () => h(NButton, powerBtnProps(row, 'STOP'), { default: () => '停止' }) },
),
h(
ConfirmPop,
{
title: `重启实例「${row.displayName}」?`,
content: '实例将立即重启,期间服务中断。',
kind: 'warn',
confirmText: '重启',
onConfirm: () => power(row, 'RESET'),
},
{ trigger: () => h(NButton, powerBtnProps(row, 'RESET'), { default: () => '重启' }) },
),
h(
NButton,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'RESET') },
{ default: () => '重启' },
),
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', disabled: isEnded(row), onClick: () => confirmTerminate(row) },
{
size: 'tiny',
quaternary: true,
type: 'error',
disabled: isEnded(row) || powering.value.has(row.id),
onClick: () => confirmTerminate(row),
},
{ default: () => '终止' },
),
]),
@@ -210,7 +269,7 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
{{ scope.currentConfig?.alias ?? '…' }}
</span>
</div>
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true">
<NButton v-if="!isDead" size="medium" type="primary" @click="showCreate = true">
创建实例
</NButton>
</div>
@@ -279,24 +338,39 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
<div class="mt-2.5 flex items-center gap-1 border-t border-line-soft pt-2">
<NButton
v-if="r.lifecycleState === 'STOPPED'"
size="tiny"
quaternary
:disabled="isEnded(r)"
v-bind="powerBtnProps(r, 'START')"
@click="power(r, 'START')"
>
启动
</NButton>
<NButton v-else size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'STOP')">
停止
</NButton>
<NButton size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'RESET')">
重启
</NButton>
<ConfirmPop
v-else
:title="`停止实例「${r.displayName}」?`"
content="实例将关机,其上服务与连接中断。"
kind="warn"
confirm-text="停止"
:on-confirm="() => power(r, 'STOP')"
>
<template #trigger>
<NButton v-bind="powerBtnProps(r, 'STOP')">停止</NButton>
</template>
</ConfirmPop>
<ConfirmPop
:title="`重启实例「${r.displayName}」?`"
content="实例将立即重启,期间服务中断。"
kind="warn"
confirm-text="重启"
:on-confirm="() => power(r, 'RESET')"
>
<template #trigger>
<NButton v-bind="powerBtnProps(r, 'RESET')">重启</NButton>
</template>
</ConfirmPop>
<NButton
size="tiny"
quaternary
type="error"
:disabled="isEnded(r)"
:disabled="isEnded(r) || powering.has(r.id)"
@click="confirmTerminate(r)"
>
终止
+165 -9
View File
@@ -1,14 +1,30 @@
<script setup lang="ts">
import { NButton, NInput, NSpin } from 'naive-ui'
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser'
import { NButton, NDropdown, NInput, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getOauthAuthorizeUrl, getOauthProviders, login } from '@/api/auth'
import {
beginPasskeyLogin,
finishPasskeyLogin,
getOauthAuthorizeUrl,
getOauthProviders,
getWalletChallenge,
login,
verifyWallet,
} from '@/api/auth'
import { ApiError } from '@/api/request'
import AppLogo from '@/components/AppLogo.vue'
import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
import { useThemeRipple } from '@/composables/useThemeRipple'
import {
discoverWallets,
isUserRejected,
personalSign,
requestAccount,
type WalletDetail,
} from '@/composables/useWallet'
import { useAuthStore } from '@/stores/auth'
import type { OauthProviderInfo } from '@/types/api'
@@ -30,8 +46,17 @@ const errorMsg = ref('')
const providers = ref<OauthProviderInfo[]>([])
// ():
const passwordLoginDisabled = ref(false)
// WebAuthn:
const passkeyAvailable = ref(false)
// :()
const walletAvailable = ref(false)
// (EIP-6963)
const walletChoices = ref<WalletDetail[]>([])
const showWalletPick = ref(false)
// :providers ,
const bootLoading = ref(true)
// :,( 403),
const bootError = ref(false)
// ://()
const featureChips = [
@@ -41,18 +66,29 @@ const featureChips = [
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
]
onMounted(async () => {
onMounted(() => {
consumeOauthCallback()
void loadProviders()
})
/** 加载登录方式能力;失败进入错误态而非默认渲染密码表单(禁用状态未知) */
async function loadProviders() {
bootLoading.value = true
bootError.value = false
try {
const r = await getOauthProviders()
providers.value = r.providers
passwordLoginDisabled.value = r.passwordLoginDisabled && r.providers.length > 0
passkeyAvailable.value = r.passkeyLogin && browserSupportsWebAuthn()
walletAvailable.value = r.walletLogin
// ;,
// ( WebAuthn 403 )
passwordLoginDisabled.value = r.passwordLoginDisabled
} catch {
/* 登录页容忍 provider 列表加载失败 */
bootError.value = true
} finally {
bootLoading.value = false
}
})
}
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
function consumeOauthCallback() {
@@ -114,6 +150,68 @@ async function oauthLogin(provider: string) {
loading.value = false
}
}
/** :;,;
* 发现期( 300ms) loading 互斥,防双击并发多组授权/挑战流程 */
async function walletLogin() {
if (loading.value) return
errorMsg.value = ''
loading.value = true
try {
const wallets = await discoverWallets()
if (!wallets.length) {
errorMsg.value = '未检测到浏览器钱包扩展(MetaMask / OKX 等)'
return
}
if (wallets.length === 1) {
await walletLoginWith(wallets[0]!)
return
}
walletChoices.value = wallets
showWalletPick.value = true
} finally {
loading.value = false
}
}
/** 钱包签名登录:连接取地址 → 后端挑战 → personal_sign → 校验签发会话 */
async function walletLoginWith(w: WalletDetail) {
showWalletPick.value = false
loading.value = true
try {
const address = await requestAccount(w.provider)
const { nonce, message } = await getWalletChallenge(address, 'login')
const signature = await personalSign(w.provider, message, address)
const resp = await verifyWallet(nonce, signature)
auth.setSession(resp.token, resp.expiresAt)
const redirect = route.query.redirect
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
} catch (e) {
if (!isUserRejected(e)) errorMsg.value = e instanceof Error ? e.message : '钱包登录失败'
} finally {
loading.value = false
}
}
/** 通行密钥一键登录:断言 options → 浏览器验证器 → 后端校验签发会话 */
async function passkeyLogin() {
loading.value = true
errorMsg.value = ''
try {
const { sessionId, options } = await beginPasskeyLogin()
const credential = await startAuthentication({ optionsJSON: options.publicKey })
const resp = await finishPasskeyLogin(sessionId, credential)
auth.setSession(resp.token, resp.expiresAt)
const redirect = route.query.redirect
await router.push(typeof redirect === 'string' ? redirect : { name: 'overview' })
} catch (e) {
// : NotAllowedError,
if (e instanceof Error && e.name === 'NotAllowedError') return
errorMsg.value = e instanceof Error ? e.message : '通行密钥登录失败'
} finally {
loading.value = false
}
}
</script>
<template>
@@ -163,6 +261,13 @@ async function oauthLogin(provider: string) {
<NSpin size="small" />
</div>
<div
v-else-if="bootError"
class="mt-5 flex flex-col items-start gap-3 rounded-md border border-line bg-wash px-4 py-3.5 text-[12.5px] text-ink-2"
>
<span>无法加载登录方式,请检查网络后重试</span>
<NButton size="small" @click="loadProviders">重试</NButton>
</div>
<template v-else>
<div
v-if="errorMsg"
@@ -206,15 +311,66 @@ async function oauthLogin(provider: string) {
{{ loading ? '验证中…' : '登 录' }}
</NButton>
</form>
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">密码登录已禁用,请使用外部身份继续</div>
<div v-else class="mt-1.5 text-[12.5px] text-ink-3">
{{
providers.length || passkeyAvailable || walletAvailable
? '密码登录已禁用,请使用免密方式继续'
: '密码登录已禁用,当前浏览器没有可用的免密登录入口(通行密钥需浏览器支持);请更换浏览器后重试'
}}
</div>
<template v-if="providers.length">
<template v-if="providers.length || passkeyAvailable || walletAvailable">
<div v-if="!passwordLoginDisabled" class="mt-5 flex items-center gap-2.5 text-[11.5px] text-ink-3">
<span class="h-px flex-1 bg-line-soft"></span>
<span class="h-px flex-1 bg-line-soft"></span>
</div>
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 gap-2'">
<div class="flex" :class="passwordLoginDisabled ? 'mt-6 flex-col gap-2.5' : 'mt-4 flex-wrap gap-2'">
<NButton
v-if="passkeyAvailable"
:block="passwordLoginDisabled"
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
:size="passwordLoginDisabled ? 'large' : 'medium'"
:disabled="loading"
@click="passkeyLogin"
>
<template #icon>
<!-- 指纹图标:通行密钥入口 -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
<path d="M8.5 10.97a3.5 3.5 0 0 1 7 .03c0 2.2-.2 4.2-.7 6" />
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
<path d="M4.8 14.5c.3-1.1.4-2.3.4-3.5" />
</svg>
</template>
{{ passwordLoginDisabled ? '使用通行密钥登录' : '通行密钥' }}
</NButton>
<NDropdown
v-if="walletAvailable"
trigger="manual"
:show="showWalletPick"
:options="walletChoices.map((w) => ({ key: w.info.uuid, label: w.info.name }))"
@select="(key: string) => { const w = walletChoices.find((x) => x.info.uuid === key); if (w) void walletLoginWith(w) }"
@clickoutside="showWalletPick = false"
>
<NButton
:block="passwordLoginDisabled"
:class="passwordLoginDisabled ? '' : 'min-w-0 flex-1'"
:size="passwordLoginDisabled ? 'large' : 'medium'"
:disabled="loading"
@click="walletLogin"
>
<template #icon>
<!-- 钱包图标 -->
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
<circle cx="16.5" cy="13.5" r="0.6" fill="currentColor" />
</svg>
</template>
{{ passwordLoginDisabled ? '使用钱包登录' : '钱包' }}
</NButton>
</NDropdown>
<NButton
v-for="p in providers"
:key="p.provider"
+1 -1
View File
@@ -155,7 +155,7 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
新建默认启用 IPv6 并放行全部出入流量可在 VCN 详情收紧
</div>
</div>
<NButton size="small" type="primary" @click="showCreate = true">创建 VCN</NButton>
<NButton size="medium" type="primary" @click="showCreate = true">创建 VCN</NButton>
</div>
<NDataTable
:columns="columns"
+1 -1
View File
@@ -262,7 +262,7 @@ const columns = computed<DataTableColumns<Bucket>>(() => [
{{ buckets.data.value?.length ?? '…' }} 个桶 · {{ scope.currentConfig?.alias ?? '…' }}
</span>
</div>
<NButton v-if="!isDead" size="small" type="primary" @click="openCreate">创建存储桶</NButton>
<NButton v-if="!isDead" size="medium" type="primary" @click="openCreate">创建存储桶</NButton>
</div>
<DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" />
+11
View File
@@ -50,6 +50,14 @@ const currencySymbol = computed(() => {
const currency = ov.value?.cost.currency
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
})
/** 主币种之外的其余币种合计;跨币种金额不可相加,只并列展示 */
const otherCurrencyTotals = computed(() =>
(ov.value?.cost.series ?? [])
.slice(1)
.map((s) => `${s.currency} ${s.total.toFixed(2)}`)
.join('、'),
)
</script>
<template>
@@ -126,6 +134,9 @@ const currencySymbol = computed(() => {
<div class="text-[12.5px] text-ink-3">
{{ costDaily.labels[0] }} {{ costDaily.labels.at(-1) }} 合计
</div>
<div v-if="otherCurrencyTotals" class="text-[12.5px] text-ink-3">
另有 {{ otherCurrencyTotals }}跨币种不相加
</div>
</div>
<div class="px-3 pb-2">
<CostChart :labels="costDaily.labels" :values="costDaily.values" />
+2 -2
View File
@@ -13,8 +13,8 @@ const panel = ref<InstanceType<typeof ProxyPanel> | null>(null)
<h1 class="page-title">代理</h1>
<span class="truncate text-[13px] text-ink-3">出站代理管理</span>
<div class="ml-auto flex flex-none items-center gap-2">
<NButton size="small" @click="panel?.openImport()">批量导入</NButton>
<NButton size="small" type="primary" @click="panel?.openCreate()">新增代理</NButton>
<NButton size="medium" @click="panel?.openImport()">批量导入</NButton>
<NButton size="medium" type="primary" @click="panel?.openCreate()">新增代理</NButton>
</div>
</div>
<ProxyPanel ref="panel" />
+36 -38
View File
@@ -21,7 +21,9 @@ import FootNote from '@/components/FootNote.vue'
import FormField from '@/components/FormField.vue'
import AboutTab from '@/components/settings/AboutTab.vue'
import AiSettingsTab from '@/components/settings/AiSettingsTab.vue'
import ChoiceChip from '@/components/ChoiceChip.vue'
import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue'
import ActiveSessionsCard from '@/components/settings/ActiveSessionsCard.vue'
import NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue'
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
import { useAsync } from '@/composables/useAsync'
@@ -42,12 +44,15 @@ const auth = useAuthStore()
// tab :( + )/ ()/ (WAF//)
const tab = ref('notify')
// OAuth :/ query, tab;
// 使,fragment token , 401
// OAuth :使,fragment token
// (setup )setup useAsync token
// , 401
consumeBindToken()
// / query, tab
onMounted(() => {
const { oauth, oauthError } = route.query
if (oauth !== 'bound' && !oauthError) return
consumeBindToken()
tab.value = 'security'
if (oauth === 'bound') message.success('外部身份绑定成功')
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
@@ -244,7 +249,7 @@ function pickHeader(v: string) {
customHeaderMode.value = false
if (securityForm.value.realIpHeader === v) return
securityForm.value.realIpHeader = v
void saveSecurity()
void saveSecurity({ realIpHeader: v })
}
function pickCustomHeader() {
@@ -265,18 +270,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
try {
await updateSecuritySetting({ ...securityForm.value })
await updateSecuritySetting(patch)
message.success('已保存,立即生效')
void security.run({ silent: true })
if (!pendingSecurityPatch) void security.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
void security.run({ silent: true })
if (!pendingSecurityPatch) void security.run({ silent: true })
} finally {
savingSecurity.value = false
const next = pendingSecurityPatch
pendingSecurityPatch = null
if (next) void saveSecurity(next)
}
}
@@ -287,7 +301,7 @@ function commitSecurityNum(
) {
if (v == null || v === securityForm.value[key]) return
securityForm.value[key] = v
void saveSecurity()
void saveSecurity({ [key]: v })
}
async function saveTaskSetting() {
@@ -581,9 +595,9 @@ async function sendTest() {
</div>
</div>
<!-- 安全:双栏 左访问防护 / 网络与地址,账号安全与登录方式 -->
<div v-else-if="tab === 'security'" class="grid max-w-[1180px] grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="flex flex-col gap-4">
<!-- 安全:方案B 全宽分段 防护与地址并排小卡,账号安全双栏,配置与会话全宽 -->
<div v-else-if="tab === 'security'" class="flex max-w-[1180px] flex-col gap-4">
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">访问防护</div>
@@ -644,39 +658,24 @@ async function sendTest() {
hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造"
>
<div class="flex flex-wrap gap-2">
<button
<ChoiceChip
v-for="opt in REALIP_PRESETS"
:key="opt.label"
type="button"
class="cursor-pointer rounded-md border px-3 py-1.5 text-xs"
:class="
!customHeaderMode && securityForm.realIpHeader === opt.value
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink-3'
"
:active="!customHeaderMode && securityForm.realIpHeader === opt.value"
@click="pickHeader(opt.value)"
>
{{ opt.label }}
</button>
<button
type="button"
class="cursor-pointer rounded-md border border-dashed px-3 py-1.5 text-xs"
:class="
customHeaderMode
? 'border-accent bg-accent/10 font-semibold text-accent'
: 'border-line bg-white text-ink-2 hover:border-ink-3'
"
@click="pickCustomHeader"
>
</ChoiceChip>
<ChoiceChip dashed :active="customHeaderMode" @click="pickCustomHeader">
自定义
</button>
</ChoiceChip>
</div>
<NInput
v-if="customHeaderMode"
v-model:value="securityForm.realIpHeader"
class="mono mt-2 !w-70"
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">
头名仅限字母数字与连字符
@@ -689,7 +688,7 @@ async function sendTest() {
<NInput
v-model:value="securityForm.appUrl"
placeholder="https://demo.example.com"
@change="() => void saveSecurity()"
@change="() => void saveSecurity({ appUrl: securityForm.appUrl })"
/>
</FormField>
</div>
@@ -699,10 +698,9 @@ async function sendTest() {
</div>
</div>
<!-- 账号安全:两步验证 / 外部身份绑定 / 登录方式配置 -->
<div class="flex flex-col gap-4">
<!-- 账号安全(密码登录/免密登录/登录方式配置)与活跃会话 -->
<AccountSecurityCard />
</div>
<ActiveSessionsCard />
</div>
<!-- AI:网关运行时设置(保险丝 / grok 工具默认 / 模型治理) -->
+43 -16
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { NButton, NSpin, useDialog } from 'naive-ui'
import { NButton, NSpin } from 'naive-ui'
import { computed, onUnmounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
import ConfirmPop from '@/components/ConfirmPop.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -18,7 +19,6 @@ import { useToast } from '@/composables/useToast'
const route = useRoute()
const router = useRouter()
const message = useToast()
const dialog = useDialog()
const scope = useScopeStore()
const taskId = computed(() => Number(route.params.taskId))
@@ -78,8 +78,12 @@ const params = computed<[string, string][]>(() => {
})
const showEdit = ref(false)
/** 正在执行的操作类别,按钮按类 loading、整组防连点(删除也计入,防与运行/编辑竞态) */
const acting = ref<'' | 'run' | 'toggle' | 'remove'>('')
async function act(fn: () => Promise<unknown>, ok: string) {
async function act(kind: 'run' | 'toggle', fn: () => Promise<unknown>, ok: string) {
if (acting.value) return
acting.value = kind
try {
await fn()
message.success(ok)
@@ -87,6 +91,8 @@ async function act(fn: () => Promise<unknown>, ok: string) {
void logs.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
acting.value = ''
}
}
@@ -96,25 +102,25 @@ function toggle() {
if (!t) return
const resume = t.status !== 'active'
void act(
'toggle',
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
)
}
function confirmRemove() {
async function doRemove() {
const t = task.data.value
if (!t) return
dialog.warning({
title: '删除任务',
content: `删除任务「${t.name}」及其全部日志?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
if (!t || acting.value) return
acting.value = 'remove'
try {
await deleteTask(t.id)
message.success('已删除任务及其日志')
void router.push({ name: 'tasks' })
},
})
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
acting.value = ''
}
}
const features = computed(() => {
@@ -174,19 +180,40 @@ const features = computed(() => {
<div class="flex flex-none items-center gap-2">
<NButton
size="small"
@click="act(() => runTask(task.data.value!.id), '已触发执行')"
:loading="acting === 'run'"
:disabled="!!acting"
@click="act('run', () => runTask(task.data.value!.id), '已触发执行')"
>
立即执行
</NButton>
<NButton
v-if="task.data.value.status !== 'succeeded'"
size="small"
:loading="acting === 'toggle'"
:disabled="!!acting"
@click="toggle"
>
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
</NButton>
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton>
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</NButton>
<NButton
v-if="task.data.value.type !== 'ai_probe'"
size="small"
:disabled="!!acting"
@click="showEdit = true"
>
编辑
</NButton>
<ConfirmPop
v-if="task.data.value.type !== 'ai_probe'"
:title="`删除任务「${task.data.value.name}」?`"
content="任务及其全部日志将一并删除。"
confirm-text="删除"
:on-confirm="doRemove"
>
<template #trigger>
<NButton size="small" type="error" quaternary :disabled="!!acting">删除</NButton>
</template>
</ConfirmPop>
</div>
</div>
+27 -7
View File
@@ -20,9 +20,19 @@ const tasks = useAsync(listTasks)
const showCreate = ref(false)
const editingTask = ref<Task | null>(null)
// 5 loading
const refreshTimer = setInterval(() => void tasks.run({ silent: true }), 5000)
onUnmounted(() => clearInterval(refreshTimer))
// 5 ( loading);,
// ,饿
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(
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),
@@ -43,13 +53,20 @@ function openCreate() {
showCreate.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) {
/** 正在操作的任务 id 集合,对应卡片操作按钮防连点 */
const acting = ref(new Set<number>())
async function act(id: number, fn: () => Promise<unknown>, ok: string) {
if (acting.value.has(id)) return
acting.value.add(id)
try {
await fn()
message.success(ok)
void tasks.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
acting.value.delete(id)
}
}
@@ -57,18 +74,20 @@ async function act(fn: () => Promise<unknown>, ok: string) {
function toggle(task: Task) {
const resume = task.status !== 'active'
void act(
task.id,
() => updateTask(task.id, { status: resume ? 'active' : 'paused' }),
resume ? (task.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
)
}
/** 删除自下拉菜单触发,popover 无锚点,保留 NDialog;确认按钮随 Promise 进入 loading */
function confirmRemove(task: Task) {
dialog.warning({
title: '删除任务',
content: `删除任务「${task.name}」及其全部日志?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: () => act(() => deleteTask(task.id), '已删除任务及其日志'),
onPositiveClick: () => act(task.id, () => deleteTask(task.id), '已删除任务及其日志'),
})
}
</script>
@@ -82,7 +101,7 @@ function confirmRemove(task: Task) {
{{ tasks.data.value?.length ?? '…' }} 个任务 · cron 为服务器本地时区
</span>
</div>
<NButton size="small" type="primary" @click="openCreate">新建任务</NButton>
<NButton size="medium" type="primary" @click="openCreate">新建任务</NButton>
</div>
<div
@@ -97,7 +116,8 @@ function confirmRemove(task: Task) {
:key="task.id"
:task="task"
:cfg-alias="aliasFor(task)"
@run="act(() => runTask(task.id), `已触发执行:${task.name}`)"
:busy="acting.has(task.id)"
@run="act(task.id, () => runTask(task.id), `已触发执行:${task.name}`)"
@toggle="toggle(task)"
@edit="openEdit(task)"
@remove="confirmRemove(task)"
+1 -1
View File
@@ -331,7 +331,7 @@ const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
<h1 class="page-title">租户</h1>
<span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span>
</div>
<NButton size="small" type="primary" @click="showImport = true">导入租户</NButton>
<NButton size="medium" type="primary" @click="showImport = true">导入租户</NButton>
</div>
<div class="panel">
+1 -1
View File
@@ -39,7 +39,7 @@ function retry() {
</div>
<div class="mt-5 flex gap-2.5">
<NButton type="warning" @click="retry">稍后重试</NButton>
<NButton @click="retry">稍后重试</NButton>
<NButton @click="router.push({ name: 'login' })">返回登录</NButton>
</div>
</div>
+7 -2
View File
@@ -1,12 +1,17 @@
{
"extends": "@tsconfig/node22/tsconfig.json",
"include": ["vite.config.ts"],
"include": [
"vite.config.ts",
"vitest.config.ts"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"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'],
},
})