4 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
51 changed files with 2411 additions and 513 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.7 0.6.10
+74 -1
View File
@@ -16,6 +16,79 @@
勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。 勿为低价值资源建冻结所有权/结果未知协议的状态机(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) - 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15) - 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16) - 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载) - `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 双处同步)。
+2 -1
View File
@@ -17,6 +17,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
| [Directory Structure](./directory-structure.md) | src 分层、文件命名与全局作用域约定 | | [Directory Structure](./directory-structure.md) | src 分层、文件命名与全局作用域约定 |
| [TypeScript & Vue](./typescript-vue.md) | TS 严格模式与组件写法 | | [TypeScript & Vue](./typescript-vue.md) | TS 严格模式与组件写法 |
| [Styling & Design Tokens](./styling-design-tokens.md) | Anthropic 风设计 token、字体与反模式清单 | | [Styling & Design Tokens](./styling-design-tokens.md) | Anthropic 风设计 token、字体与反模式清单 |
| [Buttons](./buttons.md) | 按钮七变体、尺寸档位与危险两段式守则 |
| [API & State](./api-and-state.md) | 请求封装与 Pinia 使用边界 | | [API & State](./api-and-state.md) | 请求封装与 Pinia 使用边界 |
--- ---
@@ -28,7 +29,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
- [ ] 读过 [Directory Structure](./directory-structure.md),文件放对层(views / components / composables / api / stores / types) - [ ] 读过 [Directory Structure](./directory-structure.md),文件放对层(views / components / composables / api / stores / types)
- [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex - [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex
- [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应) - [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应)
- [ ] 出 UI 设计稿前读 `../../guides/design-workflow.md` - [ ] 出 UI 设计稿前读 `../guides/design-workflow.md`
## Quality Check ## Quality Check
@@ -71,6 +71,16 @@
- **浮层投影**:下拉/确认弹层/整页提示卡统一 `shadow-overlay`(@theme `--shadow-overlay`),圆角不超过 `rounded-lg`;禁止再写 `shadow-[0_18px…]` 一类逐处任意值。 - **浮层投影**:下拉/确认弹层/整页提示卡统一 `shadow-overlay`(@theme `--shadow-overlay`),圆角不超过 `rounded-lg`;禁止再写 `shadow-[0_18px…]` 一类逐处任意值。
- **恒暗页面**(网页 VNC 等):色值从 `@/theme/tokens``darkTokens` 取,不硬编码 hex;需要给组件 prop 传具体色时同样从 tokens/darkTokens 引。 - **恒暗页面**(网页 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 ## Common Mistake: NModal 宽度不能用 Tailwind class
**Symptom**:`<NModal preset="card" class="w-[480px]">` 弹窗实际渲染为全宽。 **Symptom**:`<NModal preset="card" class="w-[480px]">` 弹窗实际渲染为全宽。
+2 -1
View File
@@ -14,7 +14,8 @@
- `v-for` 必须带 `key`,且不与 `v-if` 写在同一元素上。 - `v-for` 必须带 `key`,且不与 `v-if` 写在同一元素上。
- 组件内样式一律 `scoped`;跨组件复用样式上提到全局 token 或工具类。 - 组件内样式一律 `scoped`;跨组件复用样式上提到全局 token 或工具类。
- 复用逻辑抽 composable(`useXxx` 命名,放 `composables/`);单文件组件函数遵循 ≤30 行的项目约定。 - 复用逻辑抽 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。 - 文件类型图标用 `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。 - 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。 - 对象内容数据面分两路(round11 起):预览/编辑走面板中转 `getObjectContent`/`putObjectContent`(GET ≤20MB / PUT ≤5MB,保存以 If-Match 防并发,412 走覆盖确认),**不签发 PAR**;分享/下载/上传仍走 PAR 直连,勿把大流量改经面板。对象查看统一入口是 `ObjectViewerModal`(预览编辑合一,左内容右信息),office/pdf 用 `@vue-office/*` 且必须 `defineAsyncComponent` 动态加载保持独立 chunk。
+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.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.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
- 1.2 Research `[optional · repeatable]` - 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.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
- 1.5 Completion criteria - 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 therefore must cover every required step from implementation through
commit, including Phase 3.3 spec update and Phase 3.4 commit. --> 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] [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. 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. 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`. - Planning or unclear requirements -> `trellis-brainstorm`.
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`. - `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`. - 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] [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. 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: Spawn the research sub-agent:
@@ -361,11 +361,11 @@ Spawn the research sub-agent:
- **Task description**: Research <specific question> - **Task description**: Research <specific question>
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/` - **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] [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] [/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]` #### 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. 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. 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] [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) | ✅ | | `design.md` exists (complex tasks) | ✅ |
| `implement.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) | ✅ | | `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]` #### 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: Spawn the implement sub-agent:
- **Agent type**: `trellis-implement` - **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 - **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: The platform hook/plugin auto-handles:
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt - 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 - 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: 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 - 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 - 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] [Kiro]
@@ -526,13 +527,13 @@ The platform prelude auto-handles the context load requirement:
#### 2.2 Quality check `[required · repeatable]` #### 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: Spawn the check sub-agent:
- **Agent type**: `trellis-check` - **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 - **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: The check agent's job:
- Review code changes against specs - Review code changes against specs
@@ -540,7 +541,7 @@ The check agent's job:
- Auto-fix issues it finds - Auto-fix issues it finds
- Run lint and typecheck to verify - 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] [codex-inline, Kilo, Antigravity, Devin]
+25
View File
@@ -2,6 +2,31 @@
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。 格式参考 [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] ## [0.8.1]
### Added ### Added
+9 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"version": "0.8.0", "version": "0.8.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"version": "0.8.0", "version": "0.8.3",
"dependencies": { "dependencies": {
"@codemirror/lang-css": "^6.3.1", "@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1", "@codemirror/lang-go": "^6.0.1",
@@ -21,6 +21,7 @@
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@prettier/plugin-xml": "^3.4.2", "@prettier/plugin-xml": "^3.4.2",
"@simplewebauthn/browser": "^13.3.0",
"@vue-office/docx": "^1.6.3", "@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14", "@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10", "@vue-office/pdf": "^2.0.10",
@@ -3414,6 +3415,12 @@
"win32" "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": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "oci-portal-dash", "name": "oci-portal-dash",
"private": true, "private": true,
"version": "0.8.1", "version": "0.8.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -26,6 +26,7 @@
"@codemirror/legacy-modes": "^6.5.3", "@codemirror/legacy-modes": "^6.5.3",
"@novnc/novnc": "^1.7.0", "@novnc/novnc": "^1.7.0",
"@prettier/plugin-xml": "^3.4.2", "@prettier/plugin-xml": "^3.4.2",
"@simplewebauthn/browser": "^13.3.0",
"@vue-office/docx": "^1.6.3", "@vue-office/docx": "^1.6.3",
"@vue-office/excel": "^1.7.14", "@vue-office/excel": "^1.7.14",
"@vue-office/pdf": "^2.0.10", "@vue-office/pdf": "^2.0.10",
+160 -7
View File
@@ -1,3 +1,10 @@
import type {
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/browser'
import { mockOn, mocked, request } from './request' import { mockOn, mocked, request } from './request'
import type { import type {
CredentialsInfo, CredentialsInfo,
@@ -5,6 +12,9 @@ import type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
OAuthSettings, OAuthSettings,
PasskeyCeremonyOptions,
PasskeyInfo,
SessionItem,
SessionRefresh, SessionRefresh,
TotpSetup, TotpSetup,
TotpStatus, TotpStatus,
@@ -32,7 +42,36 @@ export function logout(): Promise<void> {
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */ /** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
export function revokeSessions(): Promise<SessionRefresh> { export function revokeSessions(): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) 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 全部失效,响应带操作者的新会话 */ /** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> { export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400) 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 全部失效,响应带新会话 */ /** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */
export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> { export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) 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) ---- // ---- 两步验证(TOTP) ----
@@ -74,22 +117,29 @@ export function setupTotp(): Promise<TotpSetup> {
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */ /** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
export function activateTotp(code: string): Promise<SessionRefresh> { export function activateTotp(code: string): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) 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> { 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) 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) ---- // ---- 外部身份(OAuth) ----
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回); /** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */ * passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
export function getOauthProviders(): Promise<{ export function getOauthProviders(): Promise<{
providers: OauthProviderInfo[] providers: OauthProviderInfo[]
passwordLoginDisabled: boolean passwordLoginDisabled: boolean
passkeyLogin: boolean
walletLogin: boolean
}> { }> {
if (mockOn) if (mockOn)
return mocked({ return mocked({
@@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{
{ provider: 'oidc', displayName: 'OIDC SSO' }, { provider: 'oidc', displayName: 'OIDC SSO' },
], ],
passwordLoginDisabled: false, passwordLoginDisabled: false,
passkeyLogin: true,
walletLogin: true,
}) })
return request('/auth/oauth/providers') return request('/auth/oauth/providers')
} }
@@ -124,7 +176,108 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
/** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */ /** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */
export function unbindIdentity(id: number): Promise<SessionRefresh> { export function unbindIdentity(id: number): Promise<SessionRefresh> {
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300) 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 配置(设置页) ---- // ---- OAuth provider 配置(设置页) ----
+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, mockVolAttachments,
mockVolumes, mockVolumes,
} from './mock' } from './mock'
import { mockOn, mocked, request } from './request' import { reactive } from 'vue'
import { ApiError, mockOn, mocked, request } from './request'
import type { import type {
AttachVnicRequest, AttachVnicRequest,
BootVolumeAttachment, 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, cfgId: number,
instanceId: string, instanceId: string,
preserveBootVolume: boolean, preserveBootVolume: boolean,
region?: string, region?: string,
): Promise<void> { ): Promise<void> {
if (mockOn) return mocked(undefined, 600)
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, { return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
method: 'DELETE', method: 'DELETE',
query: { preserveBootVolume, region }, query: { preserveBootVolume, region },
+164
View File
@@ -21,6 +21,27 @@ function stub401(rotateTo?: string) {
}) })
} }
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> let assignSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => { beforeEach(() => {
@@ -34,6 +55,89 @@ afterEach(() => {
vi.restoreAllMocks() 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 令牌快照', () => { describe('401 令牌快照', () => {
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => { it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
setPageURL('http://localhost/instances?tab=list') setPageURL('http://localhost/instances?tab=list')
@@ -55,6 +159,66 @@ describe('401 令牌快照', () => {
expect(assignSpy).not.toHaveBeenCalled() 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 () => { it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
setPageURL('http://localhost/login') setPageURL('http://localhost/login')
stub401() stub401()
+125 -23
View File
@@ -20,8 +20,27 @@ interface RequestOptions {
query?: Record<string, string | number | boolean | undefined> query?: Record<string, string | number | boolean | undefined>
/** 附加请求头 */ /** 附加请求头 */
headers?: Record<string, string> 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 { function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return BASE + path if (!query) return BASE + path
const qs = new URLSearchParams() const qs = new URLSearchParams()
@@ -32,40 +51,46 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
return s ? `${BASE}${path}?${s}` : BASE + path return s ? `${BASE}${path}?${s}` : BASE + path
} }
async function parseError(resp: Response, sentToken: string): Promise<never> { async function parseError(
let message = `请求失败(${resp.status}` resp: Response,
let ociCode: string | undefined 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 { try {
const body = (await resp.json()) as { const body = (await resp.json()) as {
error?: string error?: string
hint?: string hint?: string
errors?: unknown[] errors?: unknown[]
ociCode?: string 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') 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 { } catch {
/* 非 JSON 响应体,保留默认消息 */ return fallback
} }
if (resp.status === 401) handleUnauthorized(sentToken)
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message, ociCode)
} }
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新 /** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话; * token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */ * 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
function handleUnauthorized(sentToken: string) { async function handleUnauthorized(sentToken: string) {
const waited = await waitForSessionRefresh(sentToken)
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
const auth = useAuthStore() const auth = useAuthStore()
if (sentToken !== auth.token) return if (sentToken !== auth.token) return
auth.logout() auth.logout()
@@ -75,8 +100,80 @@ function handleUnauthorized(sentToken: string) {
} }
} }
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */ /** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> { 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 auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers } const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}` if (auth.token) headers.Authorization = `Bearer ${auth.token}`
@@ -86,10 +183,15 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
headers, headers,
body: opts.body === undefined ? undefined : JSON.stringify(opts.body), body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
}) })
if (!resp.ok) await parseError(resp, sentToken) if (!resp.ok) {
if (resp.status === 401) finishRefresh()
await parseError(resp, sentToken)
}
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input // 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text() const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T const result = (text ? JSON.parse(text) : undefined) as T
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
return result
} }
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */ /** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
+56 -8
View File
@@ -13,6 +13,7 @@ import {
import { mockOn, mocked, rawFetch, request } from './request' import { mockOn, mocked, rawFetch, request } from './request'
import type { import type {
AuditEventsResult, AuditEventsResult,
CreatedApiKey,
IamUser, IamUser,
IdentityDomain, IdentityDomain,
IamUserDetail, IamUserDetail,
@@ -26,6 +27,7 @@ import type {
SignOnRule, SignOnRule,
SubscriptionDetail, SubscriptionDetail,
SubscriptionSummary, SubscriptionSummary,
UserApiKey,
} from '@/types/api' } from '@/types/api'
// ---- 配额 ---- // ---- 配额 ----
@@ -169,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 } }) return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } })
} }
export function deleteUserApiKeys( // ---- 用户 API 签名 Key ----
id: number,
userId: string, const mockCreatedKey: CreatedApiKey = {
includeCurrent = false, fingerprint: '11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff',
): Promise<{ deletedApiKeys: number }> { privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMOCKMOCKMOCK\n-----END RSA PRIVATE KEY-----\n',
if (mockOn) return mocked({ deletedApiKeys: 1 }) configIni:
return request(`/oci-configs/${id}/users/${userId}/api-keys`, { '[DEFAULT]\nuser=ocid1.user.oc1..mock\nfingerprint=11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff\ntenancy=ocid1.tenancy.oc1..mock\nregion=ap-tokyo-1\nkey_file=~/.oci/oci_api_key.pem\n',
}
export function listUserApiKeys(id: number, userId: string): Promise<{ items: UserApiKey[] }> {
if (mockOn)
return mocked({
items: [
{
fingerprint: 'aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99',
timeCreated: '2026-07-01T08:00:00Z',
isCurrent: true,
configIni: mockCreatedKey.configIni,
},
{
fingerprint: '99:88:77:66:55:44:33:22:11:00:ff:ee:dd:cc:bb:aa',
timeCreated: '2026-07-10T08:00:00Z',
isCurrent: false,
configIni: mockCreatedKey.configIni,
},
],
})
return request(`/oci-configs/${id}/users/${userId}/api-keys`)
}
/** 后端生成密钥对并上传公钥;私钥仅本次返回 */
export function addUserApiKey(id: number, userId: string): Promise<CreatedApiKey> {
if (mockOn) return mocked({ ...mockCreatedKey })
return request(`/oci-configs/${id}/users/${userId}/api-keys`, { method: 'POST' })
}
export function deleteUserApiKey(id: number, userId: string, fingerprint: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/users/${userId}/api-keys/${encodeURIComponent(fingerprint)}`, {
method: 'DELETE', method: 'DELETE',
query: { includeCurrent }, })
}
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传;
* 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 },
}) })
} }
+13
View File
@@ -15,9 +15,12 @@
--color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */ --color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */
--color-accent: #c96442; --color-accent: #c96442;
--color-accent-hover: #ad4f2e; --color-accent-hover: #ad4f2e;
--color-accent-pressed: #9a4527;
--color-chart: #c15f3c; --color-chart: #c15f3c;
--color-ok: #788c5d; --color-ok: #788c5d;
--color-err: #b53333; --color-err: #b53333;
--color-err-hover: #9c2b2b;
--color-err-pressed: #872525;
--color-info: #6a9bcc; --color-info: #6a9bcc;
--color-warn: #a87514; --color-warn: #a87514;
@@ -45,9 +48,12 @@ html.dark {
--color-wash-deep: #33312d; --color-wash-deep: #33312d;
--color-accent: #d97757; --color-accent: #d97757;
--color-accent-hover: #e08b6d; --color-accent-hover: #e08b6d;
--color-accent-pressed: #c4674a;
--color-chart: #d97757; --color-chart: #d97757;
--color-ok: #8fa574; --color-ok: #8fa574;
--color-err: #d06060; --color-err: #d06060;
--color-err-hover: #d97070;
--color-err-pressed: #b85454;
--color-info: #7fabd6; --color-info: #7fabd6;
--color-warn: #c29135; --color-warn: #c29135;
color-scheme: dark; color-scheme: dark;
@@ -104,6 +110,13 @@ body {
background: color-mix(in srgb, var(--color-ink) 22%, transparent); 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 只会翻转不会平移,窄屏下 /* 租户选择器面板(移动端):popover follower 只会翻转不会平移,窄屏下
bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切; bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切;
改为覆盖 follower 定位,固定顶部整宽展示(follower 挂 body 下,无 改为覆盖 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' : ''" :class="isMobile ? 'form-modal-mobile' : ''"
:mask-closable="!submitting" :mask-closable="!submitting"
:close-on-esc="!submitting" :close-on-esc="!submitting"
:closable="!submitting"
@update:show="emit('update:show', $event)" @update:show="emit('update:show', $event)"
> >
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 --> <!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
+13 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <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 { computed, ref, watch } from 'vue'
import { import {
@@ -8,6 +8,7 @@ import {
syncAiChannelModels, syncAiChannelModels,
testAiChannelModel, testAiChannelModel,
} from '@/api/aigateway' } from '@/api/aigateway'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useMobileModal } from '@/composables/useMobileModal' import { useMobileModal } from '@/composables/useMobileModal'
import { useToast } from '@/composables/useToast' import { useToast } from '@/composables/useToast'
import type { AiChannel, AiModelCacheItem } from '@/types/api' 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 emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
const toast = useToast() const toast = useToast()
const dialog = useDialog()
const { modalStyle, modalClass } = useMobileModal(640) const { modalStyle, modalClass } = useMobileModal(640)
const rows = ref<AiModelCacheItem[]>([]) const rows = ref<AiModelCacheItem[]>([])
@@ -99,13 +99,7 @@ async function doTest(name: string) {
} }
} }
function confirmBlacklist(name: string) { async function doBlacklist(name: string) {
dialog.warning({
title: '拉黑模型',
content: `${name}」将从全部渠道剔除,同步与探测不再入库;可在模型黑名单中移出后重新同步恢复。`,
positiveText: '拉黑',
negativeText: '取消',
onPositiveClick: async () => {
try { try {
await addAiBlacklist(name) await addAiBlacklist(name)
toast.success('已拉黑') toast.success('已拉黑')
@@ -114,8 +108,6 @@ function confirmBlacklist(name: string) {
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : '拉黑失败') toast.error(e instanceof Error ? e.message : '拉黑失败')
} }
},
})
} }
</script> </script>
@@ -179,13 +171,22 @@ function confirmBlacklist(name: string) {
> >
{{ testing === r.name ? '测试中…' : '测试' }} {{ testing === r.name ? '测试中…' : '测试' }}
</button> </button>
<ConfirmPop
:title="`拉黑模型「${r.name}」?`"
content="将从全部渠道剔除,同步与探测不再入库。"
note="可在模型黑名单中移出后重新同步恢复"
confirm-text="拉黑"
:on-confirm="() => doBlacklist(r.name)"
>
<template #trigger>
<button <button
type="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" 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> </button>
</template>
</ConfirmPop>
</div> </div>
</template> </template>
<div v-if="!shownCount" class="py-10 text-center text-xs text-ink-3"> <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"> <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 { computed, h, reactive, ref } from 'vue'
import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway' import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway'
import { listCachedRegions } from '@/api/configs' import { listCachedRegions } from '@/api/configs'
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue' import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import GroupChips from '@/components/ai/GroupChips.vue' import GroupChips from '@/components/ai/GroupChips.vue'
import PanelHeader from '@/components/PanelHeader.vue' import PanelHeader from '@/components/PanelHeader.vue'
import AppInputNumber from '@/components/AppInputNumber.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 groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
const toast = useToast() const toast = useToast()
const dialog = useDialog()
const scope = useScopeStore() const scope = useScopeStore()
/** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户); /** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户);
* 名称本身不承载跳转,单元格内截断 */ * 名称本身不承载跳转,单元格内截断 */
@@ -135,28 +135,31 @@ function openModels(ch: AiChannel) {
showModels.value = true showModels.value = true
} }
/** 正在切换启用状态的渠道 id 集合,按行 loading 防连点 */
const toggling = ref(new Set<number>())
async function toggle(ch: AiChannel) { async function toggle(ch: AiChannel) {
if (toggling.value.has(ch.id)) return
toggling.value.add(ch.id)
try { try {
await updateAiChannel(ch.id, { enabled: !ch.enabled }) await updateAiChannel(ch.id, { enabled: !ch.enabled })
toast.success(ch.enabled ? '已停用' : '已启用') toast.success(ch.enabled ? '已停用' : '已启用')
emit('changed') emit('changed')
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败') toast.error(e instanceof Error ? e.message : '操作失败')
} finally {
toggling.value.delete(ch.id)
} }
} }
function confirmRemove(ch: AiChannel) { async function doRemove(ch: AiChannel) {
dialog.warning({ try {
title: '删除渠道',
content: `删除渠道「${ch.name}」及其模型缓存?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiChannel(ch.id) await deleteAiChannel(ch.id)
toast.success('已删除') toast.success('已删除')
emit('changed') emit('changed')
}, } catch (e) {
}) toast.error(e instanceof Error ? e.message : '删除失败')
}
} }
function probeCell(r: AiChannel) { function probeCell(r: AiChannel) {
@@ -204,9 +207,18 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [ 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', 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, 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: '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"> <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 { computed, h, reactive, ref } from 'vue'
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway' import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
import ConfirmPop from '@/components/ConfirmPop.vue'
import GroupChips from '@/components/ai/GroupChips.vue' import GroupChips from '@/components/ai/GroupChips.vue'
import AppInputNumber from '@/components/AppInputNumber.vue' import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.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 emit = defineEmits<{ changed: [] }>()
const message = useToast() const message = useToast()
const dialog = useDialog()
// ---- 新建 / 编辑弹窗 ---- // ---- 新建 / 编辑弹窗 ----
const showForm = ref(false) const showForm = ref(false)
@@ -100,28 +100,31 @@ async function copyKey() {
} }
} }
/** 正在切换启用状态的密钥 id 集合,按行 loading 防连点 */
const toggling = ref(new Set<number>())
async function toggle(key: AiKey) { async function toggle(key: AiKey) {
if (toggling.value.has(key.id)) return
toggling.value.add(key.id)
try { try {
await updateAiKey(key.id, { enabled: !key.enabled }) await updateAiKey(key.id, { enabled: !key.enabled })
message.success(key.enabled ? '已停用' : '已启用') message.success(key.enabled ? '已停用' : '已启用')
emit('changed') emit('changed')
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败') message.error(e instanceof Error ? e.message : '操作失败')
} finally {
toggling.value.delete(key.id)
} }
} }
function confirmRemove(key: AiKey) { async function doRemove(key: AiKey) {
dialog.warning({ try {
title: '删除密钥',
content: `删除密钥「${key.name}」?使用该密钥的调用将立即失效。`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiKey(key.id) await deleteAiKey(key.id)
message.success('已删除') message.success('已删除')
emit('changed') emit('changed')
}, } catch (e) {
}) message.error(e instanceof Error ? e.message : '删除失败')
}
} }
const groupHints = computed(() => props.groups.filter(Boolean)) const groupHints = computed(() => props.groups.filter(Boolean))
@@ -160,9 +163,18 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
{ {
title: '操作', key: 'actions', width: 170, title: '操作', key: 'actions', width: 170,
render: (r) => h('div', { class: 'flex items-center gap-2' }, [ 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: '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> <span class="text-xs text-ink-3">小时(上限 7 )</span>
<NButton <NButton
size="tiny" size="tiny"
quaternary
:loading="togglingContentLog" :loading="togglingContentLog"
:disabled="!contentLogHours" :disabled="!contentLogHours"
@click="setContentLog(contentLogHours ?? 0)" @click="setContentLog(contentLogHours ?? 0)"
@@ -279,7 +279,8 @@ function isEditing(dir: Dir, idx: number): boolean {
<td colspan="5" class="px-3.5 py-1.5"> <td colspan="5" class="px-3.5 py-1.5">
<NButton <NButton
size="tiny" size="tiny"
dashed quaternary
type="primary"
:disabled="!!editing" :disabled="!!editing"
@click="startEdit(dir, -1)" @click="startEdit(dir, -1)"
> >
+13 -12
View File
@@ -21,6 +21,7 @@ import {
uploadViaPar, uploadViaPar,
} from '@/api/objectstorage' } from '@/api/objectstorage'
import AppInputNumber from '@/components/AppInputNumber.vue' import AppInputNumber from '@/components/AppInputNumber.vue'
import ChoiceChip from '@/components/ChoiceChip.vue'
import ConfirmPop from '@/components/ConfirmPop.vue' import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue' import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue' import FormModal from '@/components/FormModal.vue'
@@ -642,9 +643,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
<div class="flex-1" /> <div class="flex-1" />
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 --> <!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
<div class="flex items-center gap-2 max-md:w-full"> <div class="flex items-center gap-2 max-md:w-full">
<NButton size="small" @click="openParModal('')">分享桶</NButton> <NButton size="medium" @click="openParModal('')">分享桶</NButton>
<NButton size="small" @click="emit('settings')">桶设置</NButton> <NButton size="medium" @click="emit('settings')">桶设置</NButton>
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton> <NButton size="medium" type="primary" @click="pickFiles">上传对象</NButton>
</div> </div>
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" /> <input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
</div> </div>
@@ -734,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]" 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> <span class="font-medium">已选 {{ checkedKeys.length }} </span>
<NButton size="tiny" @click="batchDownload">逐个下载</NButton> <NButton size="tiny" quaternary @click="batchDownload">逐个下载</NButton>
<ConfirmPop <ConfirmPop
:title="`删除选中的 ${checkedKeys.length} 个对象?`" :title="`删除选中的 ${checkedKeys.length} 个对象?`"
content="未开版本控制时不可恢复。" content="未开版本控制时不可恢复。"
@@ -763,8 +764,10 @@ const columns = computed<DataTableColumns<Row>>(() => [
> >
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span> <span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
<span class="flex flex-none items-center gap-1.5"> <span class="flex flex-none items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton> <NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="prevPage">
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage"> 上一页
</NButton>
<NButton size="tiny" quaternary :disabled="!result.data.value?.nextStartWith" @click="nextPage">
下一页 下一页
</NButton> </NButton>
</span> </span>
@@ -922,16 +925,14 @@ const columns = computed<DataTableColumns<Row>>(() => [
class="!w-24" class="!w-24"
/> />
<span class="text-[12.5px] text-ink-2">小时</span> <span class="text-[12.5px] text-ink-2">小时</span>
<NButton <ChoiceChip
v-for="q in QUICK_HOURS" v-for="q in QUICK_HOURS"
:key="q.value" :key="q.value"
size="tiny" :active="parForm.expiresHours === q.value"
quaternary
:type="parForm.expiresHours === q.value ? 'primary' : 'default'"
@click="parForm.expiresHours = q.value" @click="parForm.expiresHours = q.value"
> >
{{ q.label }} {{ q.label }}
</NButton> </ChoiceChip>
</div> </div>
</FormField> </FormField>
<div <div
@@ -939,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" 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> <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>
<div v-if="parUrl" class="mt-1 text-xs text-warn"> <div v-if="parUrl" class="mt-1 text-xs text-warn">
链接仅本次展示,关闭后无法再次获取,请立即保存 链接仅本次展示,关闭后无法再次获取,请立即保存
+2 -2
View File
@@ -200,8 +200,8 @@ const columns = computed<DataTableColumns<Par>>(() => [
> >
<span>每页 {{ PAGE_SIZE }} 条</span> <span>每页 {{ PAGE_SIZE }} 条</span>
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="goPrev">上一页</NButton> <NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
<NButton size="tiny" :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton> <NButton size="tiny" quaternary :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
</span> </span>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton } from 'naive-ui' import ChoiceChip from '@/components/ChoiceChip.vue'
import { onBeforeUnmount, computed, ref, watch } from 'vue' import { onBeforeUnmount, computed, ref, watch } from 'vue'
import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight' import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight'
@@ -60,22 +60,8 @@ async function renderMd() {
<template> <template>
<div class="flex h-full min-h-0 flex-col"> <div class="flex h-full min-h-0 flex-col">
<div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1"> <div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1">
<NButton <ChoiceChip :active="renderMode === 'render'" @click="renderMode = 'render'">渲染</ChoiceChip>
size="tiny" <ChoiceChip :active="renderMode === 'code'" @click="renderMode = 'code'">代码</ChoiceChip>
: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>
</div> </div>
<div class="min-h-0 flex-1 overflow-auto rounded bg-white"> <div class="min-h-0 flex-1 overflow-auto rounded bg-white">
<img <img
+1 -1
View File
@@ -127,7 +127,7 @@ async function save() {
<div class="mt-1 flex items-center gap-2"> <div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton> <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> </div>
</NModal> </NModal>
</template> </template>
+271 -101
View File
@@ -1,27 +1,37 @@
<script setup lang="ts"> <script setup lang="ts">
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui' import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { discoverWallets, isUserRejected, personalSign, requestAccount } from '@/composables/useWallet'
import { import {
activateTotp, activateTotp,
beginPasskeyRegister,
disableTotp, disableTotp,
revokeSessions, finishPasskeyRegister,
getCredentials, getCredentials,
getOAuthSettings, getOAuthSettings,
getOauthAuthorizeUrl, getOauthAuthorizeUrl,
getTotpStatus, getTotpStatus,
getWalletChallenge,
listIdentities, listIdentities,
listPasskeys,
removePasskey,
setupTotp, setupTotp,
unbindIdentity, unbindIdentity,
updateCredentials, updateCredentials,
updateOAuthSettings, updateOAuthSettings,
updatePasswordLogin, updatePasswordLogin,
verifyWallet,
} from '@/api/auth' } from '@/api/auth'
import ChoiceChip from '@/components/ChoiceChip.vue'
import ConfirmPop from '@/components/ConfirmPop.vue' import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue' import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue' import FormModal from '@/components/FormModal.vue'
import FootNote from '@/components/FootNote.vue' import FootNote from '@/components/FootNote.vue'
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
import { useAsync } from '@/composables/useAsync' import { useAsync } from '@/composables/useAsync'
import { useMobileModal } from '@/composables/useMobileModal' import { useMobileModal } from '@/composables/useMobileModal'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
@@ -42,12 +52,17 @@ function applySession(r: SessionRefresh) {
if (r?.token) auth.setSession(r.token, r.expiresAt) 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 { function displayNameOf(p: string): string {
const cfg = oauthCfg.data.value 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 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 identities = useAsync(listIdentities)
const oauthCfg = useAsync(getOAuthSettings) const oauthCfg = useAsync(getOAuthSettings)
const binding = ref('') 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 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) const creds = useAsync(getCredentials)
@@ -185,12 +274,12 @@ async function saveCredentials() {
} }
} }
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */ /** 切换密码登录禁用;开启由后端校验至少一种免密方式(通行密钥或外部身份) */
async function togglePasswordLogin(disabled: boolean) { async function togglePasswordLogin(disabled: boolean) {
togglingPwLogin.value = true togglingPwLogin.value = true
try { try {
applySession(await updatePasswordLogin(disabled)) applySession(await updatePasswordLogin(disabled))
message.success(disabled ? '已禁用密码登录,仅外部身份登录' : '已恢复密码登录') message.success(disabled ? '已禁用密码登录,仅可用通行密钥或外部身份登录' : '已恢复密码登录')
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败') message.error(e instanceof Error ? e.message : '操作失败')
} finally { } finally {
@@ -199,17 +288,7 @@ async function togglePasswordLogin(disabled: boolean) {
} }
} }
/** 已配置 clientID 的 provider 才显示绑定入口 */ /** 已绑定的 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 集合;绑定入口按钮据此禁用,解绑后自动恢复 */
const boundProviders = computed( const boundProviders = computed(
() => new Set((identities.data.value?.items ?? []).map((it) => it.provider)), () => 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):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ---- // ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
type ProviderType = 'github' | 'oidc' type ProviderType = 'github' | 'oidc'
@@ -274,13 +338,20 @@ const pForm = reactive({
const providerRows = computed(() => { const providerRows = computed(() => {
const cfg = oauthCfg.data.value const cfg = oauthCfg.data.value
if (!cfg) return [] 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) if (cfg.githubClientId)
rows.push({ rows.push({
provider: 'github', provider: 'github',
name: displayNameOf('github'), name: displayNameOf('github'),
clientId: cfg.githubClientId, clientId: cfg.githubClientId,
disabled: cfg.githubDisabled, disabled: cfg.githubDisabled,
ready: cfg.githubSecretSet,
}) })
if (cfg.oidcClientId) if (cfg.oidcClientId)
rows.push({ rows.push({
@@ -288,6 +359,7 @@ const providerRows = computed(() => {
name: displayNameOf('oidc'), name: displayNameOf('oidc'),
clientId: cfg.oidcClientId, clientId: cfg.oidcClientId,
disabled: cfg.oidcDisabled, disabled: cfg.oidcDisabled,
ready: cfg.oidcSecretSet && Boolean(cfg.oidcIssuer),
}) })
return rows return rows
}) })
@@ -313,15 +385,18 @@ function openEditProvider(p: ProviderType) {
showProviderModal.value = true showProviderModal.value = true
} }
const pFormValid = computed(
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
)
const secretConfigured = computed(() => { const secretConfigured = computed(() => {
const cfg = oauthCfg.data.value const cfg = oauthCfg.data.value
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
}) })
const pFormValid = computed(
() =>
Boolean(pForm.clientId.trim()) &&
(pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())) &&
(secretConfigured.value || Boolean(pForm.secret)),
)
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */ /** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
function providerFormPatch(): UpdateOAuthRequest { function providerFormPatch(): UpdateOAuthRequest {
const name = pForm.displayName.trim() const name = pForm.displayName.trim()
@@ -390,14 +465,15 @@ async function deleteProvider(p: ProviderType) {
</script> </script>
<template> <template>
<!-- 账号安全:两步验证 + 外部身份 --> <!-- 密码登录 / 免密登录:双栏卡(窄屏纵排),方案B 布局 -->
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel"> <div class="panel">
<div <div
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5" 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="min-w-0">
<div class="text-sm font-semibold">账号安全</div> <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">登录凭据与两步验证</div>
</div> </div>
<span <span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2" class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
@@ -412,11 +488,19 @@ async function deleteProvider(p: ProviderType) {
<div class="px-4.5 py-1"> <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="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0"> <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"> <div class="mt-0.5 text-xs text-ink-3">
登录需额外输入验证器动态码 用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
</div> </div>
</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" /> <NSpin v-if="totp.loading.value" size="small" />
<NButton <NButton
v-else-if="totp.data.value?.enabled" v-else-if="totp.data.value?.enabled"
@@ -432,36 +516,14 @@ async function deleteProvider(p: ProviderType) {
</NButton> </NButton>
</div> </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">
用户名 {{ 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="min-w-0"> <div class="min-w-0">
<div class="text-[13px] font-medium">禁用密码登录</div> <div class="text-[13px] font-medium">禁用密码登录</div>
<div class="mt-0.5 text-xs text-ink-3"> <div class="mt-0.5 text-xs text-ink-3">
{{ {{
hasIdentity || creds.data.value?.passwordLoginDisabled canDisablePassword || creds.data.value?.passwordLoginDisabled
? '开启后仅外部身份登录;关闭随时恢复密码登录' ? '开启后仅可用通行密钥或外部身份登录;关闭随时恢复'
: '至少绑定一个外部身份(OAuth2)后才能开启' : '至少添加一枚通行密钥或绑定一个外部身份'
}} }}
</div> </div>
</div> </div>
@@ -469,22 +531,109 @@ async function deleteProvider(p: ProviderType) {
:value="creds.data.value?.passwordLoginDisabled ?? false" :value="creds.data.value?.passwordLoginDisabled ?? false"
size="small" size="small"
:loading="togglingPwLogin" :loading="togglingPwLogin"
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled" :disabled="!canDisablePassword && !creds.data.value?.passwordLoginDisabled"
@update:value="togglePasswordLogin" @update:value="togglePasswordLogin"
/> />
</div> </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 <div
v-for="it in identities.data.value?.items ?? []" v-for="it in identities.data.value?.items ?? []"
:key="it.id" :key="it.id"
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5" 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="min-w-0">
<div class="text-[13px] font-medium"> <div class="truncate text-[13px] font-medium">
{{ displayNameOf(it.provider) }} · {{ it.display }} {{ displayNameOf(it.provider) }} · {{ it.display }}
</div> </div>
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div> <div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
</div> </div>
</div>
<ConfirmPop <ConfirmPop
:title="`解绑 ${displayNameOf(it.provider)} 身份?`" :title="`解绑 ${displayNameOf(it.provider)} 身份?`"
content="解绑后该身份无法再登录面板" content="解绑后该身份无法再登录面板"
@@ -498,24 +647,15 @@ async function deleteProvider(p: ProviderType) {
</ConfirmPop> </ConfirmPop>
</div> </div>
<div <div v-if="passwordlessEmpty" class="border-b border-line-soft py-2.5 text-xs text-ink-3">
v-if="bindableProviders.length" 尚未添加任何免密登录方式;添加后可在登录页免密码一键登录
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5" </div>
> <div v-if="!webauthnSupported" class="pt-2.5 text-xs text-warn">
<NButton 当前环境不支持通行密钥:需通过 HTTPS(或 localhost)访问面板
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> </div>
<div class="py-2.5 text-xs text-ink-3"> <div class="py-2.5 text-xs text-ink-3">
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册) OAuth 身份在下方「登录方式配置」操作列发起绑定;未绑定的身份无法登录(不开放注册)
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -527,7 +667,7 @@ async function deleteProvider(p: ProviderType) {
> >
<div class="min-w-0"> <div class="min-w-0">
<div class="text-sm font-semibold">登录方式配置</div> <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> </div>
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton> <NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
</div> </div>
@@ -539,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">类型</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">Client ID</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-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> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -557,11 +697,24 @@ async function deleteProvider(p: ProviderType) {
<span <span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2" class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
> >
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" /> <span
{{ row.disabled ? '已禁用' : '启用中' }} 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> </span>
</td> </td>
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap"> <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" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
<NButton <NButton
size="tiny" size="tiny"
@@ -590,7 +743,7 @@ async function deleteProvider(p: ProviderType) {
</div> </div>
<FootNote> <FootNote>
在 IdP / GitHub 应用中填写的授权回调地址: 在 IdP / GitHub 应用中填写的授权回调地址:
{{ callbackBase}}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback {{ callbackBase }}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback;已绑定的身份在上方「免密登录」中解绑
</FootNote> </FootNote>
</div> </div>
@@ -607,22 +760,15 @@ async function deleteProvider(p: ProviderType) {
> >
<FormField label="类型" required> <FormField label="类型" required>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <ChoiceChip
v-for="t in PROVIDER_TYPES" v-for="t in PROVIDER_TYPES"
:key="t.value" :key="t.value"
type="button" :active="pForm.provider === t.value"
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',
]"
:disabled="!!editingProvider" :disabled="!!editingProvider"
@click="pForm.provider = t.value" @click="pForm.provider = t.value"
> >
{{ t.label }} {{ t.label }}
</button> </ChoiceChip>
</div> </div>
</FormField> </FormField>
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}`"> <FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}`">
@@ -653,6 +799,7 @@ async function deleteProvider(p: ProviderType) {
</FormField> </FormField>
<FormField <FormField
label="Client Secret" label="Client Secret"
required
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'" :hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
> >
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" /> <NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
@@ -704,6 +851,29 @@ async function deleteProvider(p: ProviderType) {
</FormField> </FormField>
</FormModal> </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 <FormModal
:show="showTotpDisable" :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" :key="p.label"
size="tiny" size="tiny"
quaternary quaternary
type="primary"
@click="form.bodyTemplate = p.tpl" @click="form.bodyTemplate = p.tpl"
> >
{{ p.label }} {{ 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 { fmtRelative } from '@/composables/useFormat'
import type { Task } from '@/types/api' 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 emit = defineEmits<{ run: []; toggle: []; edit: []; remove: [] }>()
const router = useRouter() const router = useRouter()
@@ -79,7 +79,7 @@ function goDetail() {
<div class="flex flex-none items-center gap-0.5" @click.stop> <div class="flex flex-none items-center gap-0.5" @click.stop>
<NTooltip v-if="task.status !== 'succeeded'"> <NTooltip v-if="task.status !== 'succeeded'">
<template #trigger> <template #trigger>
<NButton size="small" quaternary circle @click="emit('toggle')"> <NButton size="small" quaternary :disabled="busy" @click="emit('toggle')">
<svg <svg
class="h-[15px] w-[15px]" class="h-[15px] w-[15px]"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -98,7 +98,7 @@ function goDetail() {
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton size="small" quaternary circle @click="emit('run')"> <NButton size="small" quaternary :disabled="busy" @click="emit('run')">
<svg <svg
class="h-[15px] w-[15px]" class="h-[15px] w-[15px]"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -116,7 +116,7 @@ function goDetail() {
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton size="small" quaternary circle @click="goDetail"> <NButton size="small" quaternary @click="goDetail">
<svg <svg
class="h-[15px] w-[15px]" class="h-[15px] w-[15px]"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -132,7 +132,7 @@ function goDetail() {
详情与事件流 详情与事件流
</NTooltip> </NTooltip>
<NDropdown v-if="moreOptions.length" :options="moreOptions" trigger="click" @select="onMore"> <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"> <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="5" r="1.6" />
<circle cx="12" cy="12" r="1.6" /> <circle cx="12" cy="12" r="1.6" />
+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>
+66 -32
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NDataTable, NSwitch, useDialog, type DataTableColumns } from 'naive-ui' import { NButton, NDataTable, NSwitch, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue' import { computed, h, ref } from 'vue'
import { import {
activateIdp, activateIdp,
@@ -22,10 +22,15 @@ import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>() const props = defineProps<{ cfgId: number; domainId?: string }>()
const message = useToast() const message = useToast()
const dialog = useDialog()
const idps = useAsync(() => listIdps(props.cfgId, props.domainId)) const idps = useAsync(() => listIdps(props.cfgId, props.domainId))
const rules = useAsync(() => listSignOnRules(props.cfgId, props.domainId)) const rules = useAsync(() => listSignOnRules(props.cfgId, props.domainId))
const showCreate = ref(false) 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) { async function act(fn: () => Promise<unknown>, ok: string) {
try { 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> = [ const idpColumns: DataTableColumns<IdentityProvider> = [
{ {
title: 'IdP', title: 'IdP',
@@ -75,13 +90,24 @@ const idpColumns: DataTableColumns<IdentityProvider> = [
h(NSwitch, { h(NSwitch, {
size: 'small', size: 'small',
value: row.enabled, value: row.enabled,
onUpdateValue: (v: boolean) => loading: toggling.value.has(row.id),
act(() => activateIdp(props.cfgId, row.id, v, props.domainId), v ? '已激活并显示到登录页' : '已停用'), disabled: toggling.value.has(row.id),
onUpdateValue: (v: boolean) => toggleIdp(row, v),
}), }),
h( h(
NButton, ConfirmPop,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDeleteIdp(row) }, {
{ default: () => '删除' }, 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() { async function doDownloadMetadata() {
if (downloading.value) return
downloading.value = true
try { try {
await downloadSamlMetadata(props.cfgId, props.domainId) await downloadSamlMetadata(props.cfgId, props.domainId)
message.success('SAML 元数据已下载') message.success('SAML 元数据已下载')
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '下载失败') message.error(e instanceof Error ? e.message : '下载失败')
} finally {
downloading.value = false
} }
} }
function confirmDeleteIdp(row: IdentityProvider) { async function addExemption() {
dialog.warning({ const enabled = enabledIdp.value
title: '删除身份提供商', if (!enabled || exempting.value) return
content: () => exempting.value = true
h('div', { class: 'text-[13px]' }, [ try {
h('p', `确定删除 IdP「${row.name}」?此操作不可恢复。`), await act(
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(
() => createSignOnExemption(props.cfgId, enabled.id, props.domainId), () => createSignOnExemption(props.cfgId, enabled.id, props.domainId),
`已为 ${enabled.name} 创建免 MFA 规则并置顶`, `已为 ${enabled.name} 创建免 MFA 规则并置顶`,
) )
} finally {
exempting.value = false
}
} }
</script> </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="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="text-sm font-semibold">SAML 身份提供商</div>
<div class="flex items-center gap-2"> <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> <NButton size="small" type="primary" @click="showCreate = true">创建 IdP</NButton>
</div> </div>
</div> </div>
@@ -200,7 +217,24 @@ function addExemption() {
<div class="panel"> <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="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> <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> </div>
<NDataTable <NDataTable
:columns="ruleColumns" :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"> <span class="min-w-0 flex-1 text-xs text-ink-3">
推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成 推荐直接一键创建链路;仅需回调地址手动配置 OCI 侧时,可单独生成
</span> </span>
<NButton size="tiny" :loading="savingWebhook" @click="createWebhook"> <NButton size="tiny" quaternary :loading="savingWebhook" @click="createWebhook">
生成回调地址 生成回调地址
</NButton> </NButton>
</div> </div>
+38 -33
View File
@@ -2,9 +2,10 @@
import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui' import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue' import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant' import { clearUserMfa, deleteUser, listUsers, resetUserPassword } from '@/api/tenant'
import FootNote from '@/components/FootNote.vue' import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue' import StatusBadge from '@/components/StatusBadge.vue'
import ApiKeysModal from '@/components/tenant/ApiKeysModal.vue'
import UserFormModal from '@/components/tenant/UserFormModal.vue' import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat' import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync' import { useAsync } from '@/composables/useAsync'
@@ -19,6 +20,8 @@ const dialog = useDialog()
const users = useAsync(() => listUsers(props.cfgId, props.domainId)) const users = useAsync(() => listUsers(props.cfgId, props.domainId))
const showForm = ref(false) const showForm = ref(false)
const editingUser = ref<IamUser | null>(null) const editingUser = ref<IamUser | null>(null)
const showKeys = ref(false)
const keysUser = ref<IamUser | null>(null)
function openCreate() { function openCreate() {
editingUser.value = null editingUser.value = null
@@ -30,6 +33,11 @@ function openEdit(row: IamUser) {
showForm.value = true showForm.value = true
} }
function openKeys(row: IamUser) {
keysUser.value = row
showKeys.value = true
}
async function act(fn: () => Promise<unknown>, ok: string) { async function act(fn: () => Promise<unknown>, ok: string) {
try { try {
await fn() await fn()
@@ -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) { async function resetPwd(row: IamUser) {
try { try {
const { password } = await resetUserPassword(props.cfgId, row.id, props.domainId) const { password } = await resetUserPassword(props.cfgId, row.id, props.domainId)
@@ -112,11 +106,23 @@ const columns: DataTableColumns<IamUser> = [
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 290, width: 300,
render: (row) => render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [ h('div', { class: 'flex items-center gap-1' }, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }), h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }), h(
ConfirmPop,
{
title: '重置密码?',
content: `将为 ${row.name} 生成一次性新密码。`,
kind: 'warn',
confirmText: '重置',
onConfirm: () => resetPwd(row),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '重置密码' }),
},
),
h( h(
ConfirmPop, ConfirmPop,
{ {
@@ -131,25 +137,22 @@ const columns: DataTableColumns<IamUser> = [
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }), trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
}, },
), ),
h( h(NButton, { size: 'tiny', quaternary: true, onClick: () => openKeys(row) }, { default: () => 'API Keys' }),
ConfirmPop,
{
title: '删除全部 API Key',
content: '跳过当前配置使用中的指纹。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
},
),
row.isCurrentUser row.isCurrentUser
? null ? null
: h( : h(
NButton, ConfirmPop,
{ size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmDelete(row) }, {
{ default: () => '删除' }, 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" :user="editingUser"
@created="users.run()" @created="users.run()"
/> />
<ApiKeysModal v-model:show="showKeys" :cfg-id="cfgId" :user="keysUser" @switched="users.run()" />
</div> </div>
</template> </template>
+26
View File
@@ -20,6 +20,32 @@ export function fmtRelative(iso: string | null | undefined): string {
return `${Math.floor(hr / 24)} 天前` 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 { export function fmtBytes(n: number): string {
if (n <= 0) return '0 B' if (n <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB'] 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
}
+4 -1
View File
@@ -9,9 +9,12 @@ function makeOverrides(t: ThemeTokens): GlobalThemeOverrides {
common: { common: {
primaryColor: t.accent, primaryColor: t.accent,
primaryColorHover: t.accentHover, primaryColorHover: t.accentHover,
primaryColorPressed: t.accentHover, primaryColorPressed: t.accentPressed,
primaryColorSuppl: t.accent, primaryColorSuppl: t.accent,
errorColor: t.err, errorColor: t.err,
errorColorHover: t.errHover,
errorColorPressed: t.errPressed,
errorColorSuppl: t.err,
successColor: t.ok, successColor: t.ok,
infoColor: t.info, infoColor: t.info,
warningColor: t.warn, warningColor: t.warn,
+6
View File
@@ -16,9 +16,12 @@ export const tokens = {
washDeep: '#ECE9DF', washDeep: '#ECE9DF',
accent: '#C96442', accent: '#C96442',
accentHover: '#AD4F2E', accentHover: '#AD4F2E',
accentPressed: '#9A4527',
chart: '#C15F3C', chart: '#C15F3C',
ok: '#788C5D', ok: '#788C5D',
err: '#B53333', err: '#B53333',
errHover: '#9C2B2B',
errPressed: '#872525',
info: '#6A9BCC', info: '#6A9BCC',
warn: '#A87514', warn: '#A87514',
fontSans: fontSans:
@@ -45,9 +48,12 @@ export const darkTokens: ThemeTokens = {
washDeep: '#33312D', washDeep: '#33312D',
accent: '#D97757', accent: '#D97757',
accentHover: '#E08B6D', accentHover: '#E08B6D',
accentPressed: '#C4674A',
chart: '#D97757', chart: '#D97757',
ok: '#8FA574', ok: '#8FA574',
err: '#D06060', err: '#D06060',
errHover: '#D97070',
errPressed: '#B85454',
info: '#7FABD6', info: '#7FABD6',
warn: '#C29135', warn: '#C29135',
} }
+42
View File
@@ -85,6 +85,33 @@ export interface OauthProviderInfo {
displayName: string 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 配置(租户)---- // ---- API Key 配置(租户)----
export type AccountType = 'paid' | 'trial' | 'free' | 'unknown' export type AccountType = 'paid' | 'trial' | 'free' | 'unknown'
export type AliveStatus = 'alive' | 'dead' | 'suspended' | 'unknown' export type AliveStatus = 'alive' | 'dead' | 'suspended' | 'unknown'
@@ -639,6 +666,21 @@ export interface IamUser {
lastLoginTime: string | null lastLoginTime: string | null
} }
// 用户 API 签名 key;isCurrent 表示当前配置正用它签名,configIni 为该 key 的 CLI 配置模板
export interface UserApiKey {
fingerprint: string
timeCreated: string | null
isCurrent: boolean
configIni: string
}
// 新建/轮换 key 的一次性返回;私钥仅此一次,关闭即不可再取
export interface CreatedApiKey {
fingerprint: string
privateKey: string
configIni: string
}
// 编辑表单用的域档案与管理员状态;inDomain 为 false 表示经典 IAM 用户 // 编辑表单用的域档案与管理员状态;inDomain 为 false 表示经典 IAM 用户
export interface IamUserDetail { export interface IamUserDetail {
inDomain: boolean inDomain: boolean
+79 -19
View File
@@ -23,6 +23,8 @@ import {
listConsoleConnections, listConsoleConnections,
listVolumeAttachments, listVolumeAttachments,
terminateInstance, terminateInstance,
terminatingInstances,
terminatingKey,
updateInstance, updateInstance,
} from '@/api/instances' } from '@/api/instances'
import EmptyCard from '@/components/EmptyCard.vue' import EmptyCard from '@/components/EmptyCard.vue'
@@ -226,11 +228,20 @@ async function act(fn: () => Promise<unknown>, ok: string) {
} }
} }
function power(action: PowerAction) { /** 电源操作在飞行中的动作,对应按钮 loading、整组防连点 */
void act( 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), () => instanceAction(cfgId.value, instanceId.value, action, region.value),
`${action} 已提交`, `${action} 已提交`,
) )
} finally {
powering.value = ''
}
} }
const moreOptions = [ const moreOptions = [
@@ -238,22 +249,35 @@ const moreOptions = [
{ label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' }, { label: 'SOFTRESET(系统内重启)', key: 'SOFTRESET' },
] ]
/** 终止飞行中(api 层共享锁):跨 Dialog/视图/重挂载互斥 */
const terminating = computed(() =>
terminatingInstances.has(terminatingKey(cfgId.value, instanceId.value)),
)
/** 首次点击即锁定两个互斥分支,防慢请求期间「删除/保留引导卷」并发提交 */
function terminate() { 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: '终止实例', title: '终止实例',
content: '终止后实例不可恢复。是否同时保留引导卷?', content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷', positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷', negativeText: '终止但保留引导卷',
onPositiveClick: () => onPositiveClick: () => submit(false),
act( onNegativeClick: () => submit(true),
() => terminateInstance(cfgId.value, instanceId.value, false, region.value),
'终止请求已提交',
),
onNegativeClick: () =>
act(
() => terminateInstance(cfgId.value, instanceId.value, true, region.value),
'终止请求已提交(保留引导卷)',
),
}) })
} }
@@ -426,20 +450,56 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
<div class="flex-1" /> <div class="flex-1" />
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 --> <!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
<div class="flex items-center gap-2 max-md:w-full"> <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>
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton> <ConfirmPop
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton> v-else
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate"> 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> </NButton>
<NDropdown <NDropdown
:options="moreOptions" :options="moreOptions"
:disabled="isEnded" :disabled="isEnded || !!powering || terminating"
@select="(key: string) => power(key as PowerAction)" @select="(key: string) => power(key as PowerAction)"
> >
<NButton size="small" quaternary :disabled="isEnded"></NButton> <NButton size="small" quaternary :disabled="isEnded || !!powering || terminating"></NButton>
</NDropdown> </NDropdown>
</div> </div>
</div> </div>
+100 -26
View File
@@ -11,8 +11,15 @@ import {
import { computed, h, ref, watch } from 'vue' import { computed, h, ref, watch } from 'vue'
import { RouterLink } from 'vue-router' 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 CompartmentSelect from '@/components/CompartmentSelect.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import DeadPlaceholder from '@/components/DeadPlaceholder.vue' import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue' import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue' import LifecycleBadge from '@/components/LifecycleBadge.vue'
@@ -83,27 +90,61 @@ function isEnded(row: InstanceRow): boolean {
return row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING' return row.lifecycleState === 'TERMINATED' || row.lifecycleState === 'TERMINATING'
} }
/** 行内电源操作在飞行中:实例 id → 动作;对应按钮 loading,整行防连点 */
const powering = ref(new Map<string, PowerAction>())
async function power(row: InstanceRow, action: PowerAction) { async function power(row: InstanceRow, action: PowerAction) {
if (powering.value.has(row.id)) return
powering.value.set(row.id, action)
try { try {
await instanceAction(row.cfgId, row.id, action, scope.region || undefined) await instanceAction(row.cfgId, row.id, action, scope.region || undefined)
message.success(`${action} 已提交:${row.displayName}`) message.success(`${action} 已提交:${row.displayName}`)
void rows.run() void rows.run()
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败') 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() const dialog = useDialog()
/** 与实例详情页一致:双选是否保留引导卷,返回 Promise 让 dialog 按钮 loading */ /** :, Promise dialog loading;
* 首次点击即锁定两个互斥分支; Dialog/视图/重挂载的互斥由 api
* terminatingInstances 共享锁承担 */
function confirmTerminate(row: InstanceRow) { 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}`, title: `终止实例 ${row.displayName}`,
content: '终止后实例不可恢复。是否同时保留引导卷?', content: '终止后实例不可恢复。是否同时保留引导卷?',
positiveText: '终止并删除引导卷', positiveText: '终止并删除引导卷',
negativeText: '终止但保留引导卷', negativeText: '终止但保留引导卷',
onPositiveClick: () => doTerminate(row, false), onPositiveClick: () => submit(false),
onNegativeClick: () => doTerminate(row, true), onNegativeClick: () => submit(true),
}) })
} }
@@ -177,22 +218,40 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
row.lifecycleState === 'STOPPED' row.lifecycleState === 'STOPPED'
? h( ? h(
NButton, NButton,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'START') }, { ...powerBtnProps(row, 'START'), onClick: () => power(row, 'START') },
{ default: () => '启动' }, { default: () => '启动' },
) )
: h( : h(
NButton, ConfirmPop,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'STOP') }, {
{ default: () => '停止' }, 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( h(
NButton, NButton,
{ size: 'tiny', quaternary: true, disabled: isEnded(row), onClick: () => power(row, 'RESET') }, {
{ default: () => '重启' }, size: 'tiny',
), quaternary: true,
h( type: 'error',
NButton, disabled: isEnded(row) || powering.value.has(row.id),
{ size: 'tiny', quaternary: true, type: 'error', disabled: isEnded(row), onClick: () => confirmTerminate(row) }, onClick: () => confirmTerminate(row),
},
{ default: () => '终止' }, { default: () => '终止' },
), ),
]), ]),
@@ -210,7 +269,7 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
{{ scope.currentConfig?.alias ?? '…' }} {{ scope.currentConfig?.alias ?? '…' }}
</span> </span>
</div> </div>
<NButton v-if="!isDead" size="small" type="primary" @click="showCreate = true"> <NButton v-if="!isDead" size="medium" type="primary" @click="showCreate = true">
创建实例 创建实例
</NButton> </NButton>
</div> </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"> <div class="mt-2.5 flex items-center gap-1 border-t border-line-soft pt-2">
<NButton <NButton
v-if="r.lifecycleState === 'STOPPED'" v-if="r.lifecycleState === 'STOPPED'"
size="tiny" v-bind="powerBtnProps(r, 'START')"
quaternary
:disabled="isEnded(r)"
@click="power(r, 'START')" @click="power(r, 'START')"
> >
启动 启动
</NButton> </NButton>
<NButton v-else size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'STOP')"> <ConfirmPop
停止 v-else
</NButton> :title="`停止实例「${r.displayName}」?`"
<NButton size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'RESET')"> content="实例将关机,其上服务与连接中断。"
重启 kind="warn"
</NButton> 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 <NButton
size="tiny" size="tiny"
quaternary quaternary
type="error" type="error"
:disabled="isEnded(r)" :disabled="isEnded(r) || powering.has(r.id)"
@click="confirmTerminate(r)" @click="confirmTerminate(r)"
> >
终止 终止
+165 -9
View File
@@ -1,14 +1,30 @@
<script setup lang="ts"> <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 { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' 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 { ApiError } from '@/api/request'
import AppLogo from '@/components/AppLogo.vue' import AppLogo from '@/components/AppLogo.vue'
import FullPageBackdrop from '@/components/FullPageBackdrop.vue' import FullPageBackdrop from '@/components/FullPageBackdrop.vue'
import OauthProviderIcon from '@/components/OauthProviderIcon.vue' import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
import { useThemeRipple } from '@/composables/useThemeRipple' import { useThemeRipple } from '@/composables/useThemeRipple'
import {
discoverWallets,
isUserRejected,
personalSign,
requestAccount,
type WalletDetail,
} from '@/composables/useWallet'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { OauthProviderInfo } from '@/types/api' import type { OauthProviderInfo } from '@/types/api'
@@ -30,8 +46,17 @@ const errorMsg = ref('')
const providers = ref<OauthProviderInfo[]>([]) const providers = ref<OauthProviderInfo[]>([])
// (): // ():
const passwordLoginDisabled = ref(false) const passwordLoginDisabled = ref(false)
// WebAuthn:
const passkeyAvailable = ref(false)
// :()
const walletAvailable = ref(false)
// (EIP-6963)
const walletChoices = ref<WalletDetail[]>([])
const showWalletPick = ref(false)
// :providers , // :providers ,
const bootLoading = ref(true) const bootLoading = ref(true)
// :,( 403),
const bootError = ref(false)
// ://() // ://()
const featureChips = [ const featureChips = [
@@ -41,18 +66,29 @@ const featureChips = [
{ label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' }, { label: '日志回传', dot: 'var(--color-warn)', style: '--bd:8.3s;--bdel:-0.8s;--bamp:-6px' },
] ]
onMounted(async () => { onMounted(() => {
consumeOauthCallback() consumeOauthCallback()
void loadProviders()
})
/** 加载登录方式能力;失败进入错误态而非默认渲染密码表单(禁用状态未知) */
async function loadProviders() {
bootLoading.value = true
bootError.value = false
try { try {
const r = await getOauthProviders() const r = await getOauthProviders()
providers.value = r.providers 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 { } catch {
/* 登录页容忍 provider 列表加载失败 */ bootError.value = true
} finally { } finally {
bootLoading.value = false bootLoading.value = false
} }
}) }
/** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */ /** 处理 OAuth 回跳:fragment 中的 token 直接建会话,query 中的错误展示后清除 */
function consumeOauthCallback() { function consumeOauthCallback() {
@@ -114,6 +150,68 @@ async function oauthLogin(provider: string) {
loading.value = false 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> </script>
<template> <template>
@@ -163,6 +261,13 @@ async function oauthLogin(provider: string) {
<NSpin size="small" /> <NSpin size="small" />
</div> </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> <template v-else>
<div <div
v-if="errorMsg" v-if="errorMsg"
@@ -206,15 +311,66 @@ async function oauthLogin(provider: string) {
{{ loading ? '验证中…' : '登 录' }} {{ loading ? '验证中…' : '登 录' }}
</NButton> </NButton>
</form> </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"> <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>
<span class="h-px flex-1 bg-line-soft"></span> <span class="h-px flex-1 bg-line-soft"></span>
</div> </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 <NButton
v-for="p in providers" v-for="p in providers"
:key="p.provider" :key="p.provider"
+1 -1
View File
@@ -155,7 +155,7 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
新建默认启用 IPv6 并放行全部出入流量可在 VCN 详情收紧 新建默认启用 IPv6 并放行全部出入流量可在 VCN 详情收紧
</div> </div>
</div> </div>
<NButton size="small" type="primary" @click="showCreate = true">创建 VCN</NButton> <NButton size="medium" type="primary" @click="showCreate = true">创建 VCN</NButton>
</div> </div>
<NDataTable <NDataTable
:columns="columns" :columns="columns"
+1 -1
View File
@@ -262,7 +262,7 @@ const columns = computed<DataTableColumns<Bucket>>(() => [
{{ buckets.data.value?.length ?? '…' }} 个桶 · {{ scope.currentConfig?.alias ?? '…' }} {{ buckets.data.value?.length ?? '…' }} 个桶 · {{ scope.currentConfig?.alias ?? '…' }}
</span> </span>
</div> </div>
<NButton v-if="!isDead" size="small" type="primary" @click="openCreate">创建存储桶</NButton> <NButton v-if="!isDead" size="medium" type="primary" @click="openCreate">创建存储桶</NButton>
</div> </div>
<DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" /> <DeadPlaceholder v-if="isDead" :last-error="scope.currentConfig?.lastError" />
+2 -2
View File
@@ -13,8 +13,8 @@ const panel = ref<InstanceType<typeof ProxyPanel> | null>(null)
<h1 class="page-title">代理</h1> <h1 class="page-title">代理</h1>
<span class="truncate text-[13px] text-ink-3">出站代理管理</span> <span class="truncate text-[13px] text-ink-3">出站代理管理</span>
<div class="ml-auto flex flex-none items-center gap-2"> <div class="ml-auto flex flex-none items-center gap-2">
<NButton size="small" @click="panel?.openImport()">批量导入</NButton> <NButton size="medium" @click="panel?.openImport()">批量导入</NButton>
<NButton size="small" type="primary" @click="panel?.openCreate()">新增代理</NButton> <NButton size="medium" type="primary" @click="panel?.openCreate()">新增代理</NButton>
</div> </div>
</div> </div>
<ProxyPanel ref="panel" /> <ProxyPanel ref="panel" />
+12 -26
View File
@@ -21,7 +21,9 @@ import FootNote from '@/components/FootNote.vue'
import FormField from '@/components/FormField.vue' import FormField from '@/components/FormField.vue'
import AboutTab from '@/components/settings/AboutTab.vue' import AboutTab from '@/components/settings/AboutTab.vue'
import AiSettingsTab from '@/components/settings/AiSettingsTab.vue' import AiSettingsTab from '@/components/settings/AiSettingsTab.vue'
import ChoiceChip from '@/components/ChoiceChip.vue'
import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue' import AccountSecurityCard from '@/components/settings/AccountSecurityCard.vue'
import ActiveSessionsCard from '@/components/settings/ActiveSessionsCard.vue'
import NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue' import NotifyChannelCard from '@/components/settings/NotifyChannelCard.vue'
import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue' import NotifyTemplateModal from '@/components/settings/NotifyTemplateModal.vue'
import { useAsync } from '@/composables/useAsync' import { useAsync } from '@/composables/useAsync'
@@ -593,9 +595,9 @@ async function sendTest() {
</div> </div>
</div> </div>
<!-- 安全:双栏 左访问防护 / 网络与地址,账号安全与登录方式 --> <!-- 安全:方案B 全宽分段 防护与地址并排小卡,账号安全双栏,配置与会话全宽 -->
<div v-else-if="tab === 'security'" class="grid max-w-[1180px] grid-cols-2 items-start gap-4 max-lg:grid-cols-1"> <div v-else-if="tab === 'security'" class="flex max-w-[1180px] flex-col gap-4">
<div class="flex flex-col gap-4"> <div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
<div class="panel"> <div class="panel">
<div class="border-b border-line-soft px-4.5 py-3.5"> <div class="border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">访问防护</div> <div class="text-sm font-semibold">访问防护</div>
@@ -656,32 +658,17 @@ async function sendTest() {
hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造" hint="Caddy/Nginx 反代选 X-Forwarded-For;Cloudflare 前置时选 CF-Connecting-IP;直连部署保持「直连」防伪造"
> >
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <ChoiceChip
v-for="opt in REALIP_PRESETS" v-for="opt in REALIP_PRESETS"
:key="opt.label" :key="opt.label"
type="button" :active="!customHeaderMode && securityForm.realIpHeader === opt.value"
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'
"
@click="pickHeader(opt.value)" @click="pickHeader(opt.value)"
> >
{{ opt.label }} {{ opt.label }}
</button> </ChoiceChip>
<button <ChoiceChip dashed :active="customHeaderMode" @click="pickCustomHeader">
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"
>
自定义 自定义
</button> </ChoiceChip>
</div> </div>
<NInput <NInput
v-if="customHeaderMode" v-if="customHeaderMode"
@@ -711,10 +698,9 @@ async function sendTest() {
</div> </div>
</div> </div>
<!-- 账号安全:两步验证 / 外部身份绑定 / 登录方式配置 --> <!-- 账号安全(密码登录/免密登录/登录方式配置)与活跃会话 -->
<div class="flex flex-col gap-4">
<AccountSecurityCard /> <AccountSecurityCard />
</div> <ActiveSessionsCard />
</div> </div>
<!-- AI:网关运行时设置(保险丝 / grok 工具默认 / 模型治理) --> <!-- AI:网关运行时设置(保险丝 / grok 工具默认 / 模型治理) -->
+43 -16
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NSpin, useDialog } from 'naive-ui' import { NButton, NSpin } from 'naive-ui'
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router' import { RouterLink, useRoute, useRouter } from 'vue-router'
import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks' import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks'
import ConfirmPop from '@/components/ConfirmPop.vue'
import EmptyCard from '@/components/EmptyCard.vue' import EmptyCard from '@/components/EmptyCard.vue'
import PanelHeader from '@/components/PanelHeader.vue' import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue' import StatusBadge from '@/components/StatusBadge.vue'
@@ -18,7 +19,6 @@ import { useToast } from '@/composables/useToast'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const message = useToast() const message = useToast()
const dialog = useDialog()
const scope = useScopeStore() const scope = useScopeStore()
const taskId = computed(() => Number(route.params.taskId)) const taskId = computed(() => Number(route.params.taskId))
@@ -78,8 +78,12 @@ const params = computed<[string, string][]>(() => {
}) })
const showEdit = ref(false) 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 { try {
await fn() await fn()
message.success(ok) message.success(ok)
@@ -87,6 +91,8 @@ async function act(fn: () => Promise<unknown>, ok: string) {
void logs.run({ silent: true }) void logs.run({ silent: true })
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败') message.error(e instanceof Error ? e.message : '操作失败')
} finally {
acting.value = ''
} }
} }
@@ -96,25 +102,25 @@ function toggle() {
if (!t) return if (!t) return
const resume = t.status !== 'active' const resume = t.status !== 'active'
void act( void act(
'toggle',
() => updateTask(t.id, { status: resume ? 'active' : 'paused' }), () => updateTask(t.id, { status: resume ? 'active' : 'paused' }),
resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停', resume ? (t.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
) )
} }
function confirmRemove() { async function doRemove() {
const t = task.data.value const t = task.data.value
if (!t) return if (!t || acting.value) return
dialog.warning({ acting.value = 'remove'
title: '删除任务', try {
content: `删除任务「${t.name}」及其全部日志?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteTask(t.id) await deleteTask(t.id)
message.success('已删除任务及其日志') message.success('已删除任务及其日志')
void router.push({ name: 'tasks' }) void router.push({ name: 'tasks' })
}, } catch (e) {
}) message.error(e instanceof Error ? e.message : '删除失败')
} finally {
acting.value = ''
}
} }
const features = computed(() => { const features = computed(() => {
@@ -174,19 +180,40 @@ const features = computed(() => {
<div class="flex flex-none items-center gap-2"> <div class="flex flex-none items-center gap-2">
<NButton <NButton
size="small" size="small"
@click="act(() => runTask(task.data.value!.id), '已触发执行')" :loading="acting === 'run'"
:disabled="!!acting"
@click="act('run', () => runTask(task.data.value!.id), '已触发执行')"
> >
立即执行 立即执行
</NButton> </NButton>
<NButton <NButton
v-if="task.data.value.status !== 'succeeded'" v-if="task.data.value.status !== 'succeeded'"
size="small" size="small"
:loading="acting === 'toggle'"
:disabled="!!acting"
@click="toggle" @click="toggle"
> >
{{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }} {{ task.data.value.status === 'active' ? '暂停' : task.data.value.status === 'failed' ? '启用' : '恢复' }}
</NButton> </NButton>
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" @click="showEdit = true">编辑</NButton> <NButton
<NButton v-if="task.data.value.type !== 'ai_probe'" size="small" type="error" quaternary @click="confirmRemove">删除</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>
</div> </div>
+14 -4
View File
@@ -53,13 +53,20 @@ function openCreate() {
showCreate.value = true 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 { try {
await fn() await fn()
message.success(ok) message.success(ok)
void tasks.run({ silent: true }) void tasks.run({ silent: true })
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '操作失败') message.error(e instanceof Error ? e.message : '操作失败')
} finally {
acting.value.delete(id)
} }
} }
@@ -67,18 +74,20 @@ async function act(fn: () => Promise<unknown>, ok: string) {
function toggle(task: Task) { function toggle(task: Task) {
const resume = task.status !== 'active' const resume = task.status !== 'active'
void act( void act(
task.id,
() => updateTask(task.id, { status: resume ? 'active' : 'paused' }), () => updateTask(task.id, { status: resume ? 'active' : 'paused' }),
resume ? (task.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停', resume ? (task.status === 'failed' ? '已重新启用' : '已恢复调度') : '已暂停',
) )
} }
/** 删除自下拉菜单触发,popover 无锚点,保留 NDialog;确认按钮随 Promise 进入 loading */
function confirmRemove(task: Task) { function confirmRemove(task: Task) {
dialog.warning({ dialog.warning({
title: '删除任务', title: '删除任务',
content: `删除任务「${task.name}」及其全部日志?`, content: `删除任务「${task.name}」及其全部日志?`,
positiveText: '删除', positiveText: '删除',
negativeText: '取消', negativeText: '取消',
onPositiveClick: () => act(() => deleteTask(task.id), '已删除任务及其日志'), onPositiveClick: () => act(task.id, () => deleteTask(task.id), '已删除任务及其日志'),
}) })
} }
</script> </script>
@@ -92,7 +101,7 @@ function confirmRemove(task: Task) {
{{ tasks.data.value?.length ?? '…' }} 个任务 · cron 为服务器本地时区 {{ tasks.data.value?.length ?? '…' }} 个任务 · cron 为服务器本地时区
</span> </span>
</div> </div>
<NButton size="small" type="primary" @click="openCreate">新建任务</NButton> <NButton size="medium" type="primary" @click="openCreate">新建任务</NButton>
</div> </div>
<div <div
@@ -107,7 +116,8 @@ function confirmRemove(task: Task) {
:key="task.id" :key="task.id"
:task="task" :task="task"
:cfg-alias="aliasFor(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)" @toggle="toggle(task)"
@edit="openEdit(task)" @edit="openEdit(task)"
@remove="confirmRemove(task)" @remove="confirmRemove(task)"
+1 -1
View File
@@ -331,7 +331,7 @@ const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
<h1 class="page-title">租户</h1> <h1 class="page-title">租户</h1>
<span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span> <span class="text-[13px] text-ink-3">{{ configs.data.value?.length ?? '…' }} 个租户</span>
</div> </div>
<NButton size="small" type="primary" @click="showImport = true">导入租户</NButton> <NButton size="medium" type="primary" @click="showImport = true">导入租户</NButton>
</div> </div>
<div class="panel"> <div class="panel">
+1 -1
View File
@@ -39,7 +39,7 @@ function retry() {
</div> </div>
<div class="mt-5 flex gap-2.5"> <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> <NButton @click="router.push({ name: 'login' })">返回登录</NButton>
</div> </div>
</div> </div>