Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8a50c4c0 | ||
|
|
d675b950ee | ||
|
|
d0fbecbd43 | ||
|
|
b0a76a1c96 | ||
|
|
f85e457b9b | ||
|
|
119f60516c |
@@ -1,6 +1,5 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
# 仅分支推送触发:tag 推送交给 release.yml;solo 开发不走 PR,去掉 pull_request 避免同一提交双跑
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
@@ -20,5 +19,7 @@ jobs:
|
|||||||
run: npx eslint .
|
run: npx eslint .
|
||||||
- name: 类型检查
|
- name: 类型检查
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
- name: 单元测试
|
||||||
|
run: npm run test
|
||||||
- name: 构建
|
- name: 构建
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
@@ -19,5 +19,7 @@ jobs:
|
|||||||
run: npx eslint .
|
run: npx eslint .
|
||||||
- name: 类型检查
|
- name: 类型检查
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
- name: 单元测试
|
||||||
|
run: npm run test
|
||||||
- name: 构建
|
- name: 构建
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
0.6.6
|
0.6.10
|
||||||
@@ -9,3 +9,86 @@
|
|||||||
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
|
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
|
||||||
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。
|
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。
|
||||||
- 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。
|
- 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。
|
||||||
|
- 表单先上传临时远端资源、再创建业务对象时:上传响应保存 `{cfgId, domainId, url, fileName}`
|
||||||
|
原始元组,用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||||||
|
失败静默(孤儿只是自己租户里一张无入口小图)。创建请求引用了它且返回 2xx
|
||||||
|
(含 `201 + setupWarning`)即视为已采用不再清理;清理复杂度须与资源价值匹配,
|
||||||
|
勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。
|
||||||
|
域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。
|
||||||
|
- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
|
||||||
|
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
|
||||||
|
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
|
||||||
|
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载)
|
||||||
|
- 写请求(非 GET)在 `request.ts` 层按 method+url+body 做 **in-flight 去重**(2026-07-23 起):前一发未返回时同 key 复用同一 Promise 不再发第二发,完成即移除;GET 不去重(useAsync seq 后发优先)。这是防连点的系统级兜底,组件级 busy/disabled 仍必须做(见 typescript-vue.md);合法的「连续两次相同写」(极少)需改变 body 或等待前次完成。
|
||||||
|
|
||||||
|
## 场景:会话换发与并发 401
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
后端成功后会令旧 JWT 失效并返回新会话的认证写接口(改密、登录策略、TOTP、
|
||||||
|
身份/通行密钥变更、撤销全部会话及钱包绑定)必须使用本契约。它防止并发旧请求的
|
||||||
|
401 先到时清空即将换发的新会话;不用于普通写接口,也不把所有写请求全局串行化。
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RequestOptions {
|
||||||
|
refreshesSession?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
request<SessionRefresh>(path, {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
refreshesSession: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`SessionRefresh` 的成功响应必须包含字符串 `token` 与 `expiresAt`。
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- 有旧 Token 的 `refreshesSession` 请求按“发出时 Token”登记在途换发。
|
||||||
|
- 2xx 响应须在 Promise 返回、换发登记释放前由请求层先写入 Auth Store;组件可为
|
||||||
|
mock 流程重复写同值。
|
||||||
|
- 其他请求的 401 若发现同一旧 Token 仍有换发在途,须等待全部结束后再比较 Store;
|
||||||
|
Token 已变化则只抛业务错误,不登出。
|
||||||
|
- 换发请求自身收到 401 时先幂等退出登记再处理 401,避免两个失败换发互相等待。
|
||||||
|
- 公开登录没有旧 Token,不由请求层提前建立会话;页面仍负责登录后导航。
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
| 条件 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| 2xx 且 `token` / `expiresAt` 合法 | 先 `setSession`,再释放等待者 |
|
||||||
|
| 网络错误、畸形 JSON、非 2xx | 不写 Store,必须释放登记 |
|
||||||
|
| 并发 401 等待后 Token 已变化 | 保留新会话,不跳登录页 |
|
||||||
|
| 等待后仍是请求时 Token | 登出并带当前地址跳转登录页 |
|
||||||
|
| 多个换发请求均返回 401 | 全部拒绝,不死锁,只执行一次有效登出 |
|
||||||
|
|
||||||
|
### 5. Good / Base / Bad Cases
|
||||||
|
|
||||||
|
- Good:改密成功响应与旧列表请求 401 并发,成功响应先落 Store,旧 401 不登出。
|
||||||
|
- Base:没有换发在途的当前 Token 收到 401,按原流程登出。
|
||||||
|
- Bad:仅在组件 `await` 后写 Store;Promise `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,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|||||||
@@ -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 双处同步)。
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
## 技术栈约束
|
## 技术栈约束
|
||||||
|
|
||||||
Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。
|
Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。选型对比与理由归档于 `.trellis/tasks/archive/2026-07/07-06-frontend-design-research/`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
|
|||||||
| [Directory Structure](./directory-structure.md) | src 分层、文件命名与全局作用域约定 |
|
| [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 使用边界 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -37,6 +38,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
|
|||||||
```bash
|
```bash
|
||||||
npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier
|
npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier
|
||||||
npm run typecheck # vue-tsc --noEmit
|
npm run typecheck # vue-tsc --noEmit
|
||||||
|
npm run test # vitest(happy-dom),*.test.ts 与被测模块同目录
|
||||||
npm run build # 类型检查 + 产物构建
|
npm run build # 类型检查 + 产物构建
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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]">` 弹窗实际渲染为全宽。
|
||||||
|
|||||||
@@ -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。
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
# Code Reuse Thinking Guide
|
|
||||||
|
|
||||||
> **Purpose**: Stop and think before creating new code - does it already exist?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
**Duplicated code is the #1 source of inconsistency bugs.**
|
|
||||||
|
|
||||||
When you copy-paste or rewrite existing logic:
|
|
||||||
- Bug fixes don't propagate
|
|
||||||
- Behavior diverges over time
|
|
||||||
- Codebase becomes harder to understand
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before Writing New Code
|
|
||||||
|
|
||||||
### Step 1: Search First
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for similar function names
|
|
||||||
grep -r "functionName" .
|
|
||||||
|
|
||||||
# Search for similar logic
|
|
||||||
grep -r "keyword" .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Ask These Questions
|
|
||||||
|
|
||||||
| Question | If Yes... |
|
|
||||||
|----------|-----------|
|
|
||||||
| Does a similar function exist? | Use or extend it |
|
|
||||||
| Is this pattern used elsewhere? | Follow the existing pattern |
|
|
||||||
| Could this be a shared utility? | Create it in the right place |
|
|
||||||
| Am I copying code from another file? | **STOP** - extract to shared |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Duplication Patterns
|
|
||||||
|
|
||||||
### Pattern 1: Copy-Paste Functions
|
|
||||||
|
|
||||||
**Bad**: Copying a validation function to another file
|
|
||||||
|
|
||||||
**Good**: Extract to shared utilities, import where needed
|
|
||||||
|
|
||||||
### Pattern 2: Similar Components
|
|
||||||
|
|
||||||
**Bad**: Creating a new component that's 80% similar to existing
|
|
||||||
|
|
||||||
**Good**: Extend existing component with props/variants
|
|
||||||
|
|
||||||
### Pattern 3: Repeated Constants
|
|
||||||
|
|
||||||
**Bad**: Defining the same constant in multiple files
|
|
||||||
|
|
||||||
**Good**: Single source of truth, import everywhere
|
|
||||||
|
|
||||||
### Pattern 4: Repeated Payload Field Extraction
|
|
||||||
|
|
||||||
**Bad**: Multiple consumers cast the same JSON/event fields locally:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const description = (ev as { description?: string }).description;
|
|
||||||
const context = (ev as { context?: ContextEntry[] }).context;
|
|
||||||
```
|
|
||||||
|
|
||||||
This is duplicated contract logic even when the code is only two lines. Each
|
|
||||||
consumer now has its own definition of what a valid payload means.
|
|
||||||
|
|
||||||
**Good**: Put the decoder, type guard, or projection next to the data owner:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (isThreadEvent(ev)) {
|
|
||||||
renderThreadEvent(ev);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule**: If the same untyped payload field is read in 2+ places, create a
|
|
||||||
shared type guard / normalizer / projection before adding a third reader.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## When to Abstract
|
|
||||||
|
|
||||||
**Abstract when**:
|
|
||||||
- Same code appears 3+ times
|
|
||||||
- Logic is complex enough to have bugs
|
|
||||||
- Multiple people might need this
|
|
||||||
|
|
||||||
**Don't abstract when**:
|
|
||||||
- Only used once
|
|
||||||
- Trivial one-liner
|
|
||||||
- Abstraction would be more complex than duplication
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## After Batch Modifications
|
|
||||||
|
|
||||||
When you've made similar changes to multiple files:
|
|
||||||
|
|
||||||
1. **Review**: Did you catch all instances?
|
|
||||||
2. **Search**: Run grep to find any missed
|
|
||||||
3. **Consider**: Should this be abstracted?
|
|
||||||
|
|
||||||
### Reducers Should Use Exhaustive Structure
|
|
||||||
|
|
||||||
When state is derived from action-like values (`action`, `kind`, `status`,
|
|
||||||
`phase`), prefer a reducer with one `switch` over scattered `if/else` updates.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// BAD - action-specific state transitions are hard to audit
|
|
||||||
if (action === "opened") { ... }
|
|
||||||
else if (action === "comment") { ... }
|
|
||||||
else if (action === "status") { ... }
|
|
||||||
|
|
||||||
// GOOD - one reducer owns the transition table
|
|
||||||
switch (event.action) {
|
|
||||||
case "opened":
|
|
||||||
...
|
|
||||||
return;
|
|
||||||
case "comment":
|
|
||||||
...
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This matters when the event log is the source of truth. A reducer is the
|
|
||||||
documented replay model; display code and commands should not duplicate pieces
|
|
||||||
of that replay model.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist Before Commit
|
|
||||||
|
|
||||||
- [ ] Searched for existing similar code
|
|
||||||
- [ ] No copy-pasted logic that should be shared
|
|
||||||
- [ ] No repeated untyped payload field extraction outside a shared decoder
|
|
||||||
- [ ] Constants defined in one place
|
|
||||||
- [ ] Similar patterns follow same structure
|
|
||||||
- [ ] Reducer/action transitions live in one reducer or command dispatcher
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotcha: Python if/elif/else Exhaustive Check
|
|
||||||
|
|
||||||
**Problem**: Python's if/elif/else chains have no compile-time exhaustive check. When you add a new value to a `Literal` type (e.g., `Platform`), existing if/elif/else chains silently fall through to `else` with wrong defaults.
|
|
||||||
|
|
||||||
**Symptom**: New platform works partially — some methods return Claude defaults instead of platform-specific values. No error is raised.
|
|
||||||
|
|
||||||
**Example** (`cli_adapter.py`):
|
|
||||||
```python
|
|
||||||
# BAD: "gemini" falls through to else, returns "claude"
|
|
||||||
@property
|
|
||||||
def cli_name(self) -> str:
|
|
||||||
if self.platform == "opencode":
|
|
||||||
return "opencode"
|
|
||||||
else:
|
|
||||||
return "claude" # gemini silently gets "claude"!
|
|
||||||
|
|
||||||
# GOOD: explicit branch for every platform
|
|
||||||
@property
|
|
||||||
def cli_name(self) -> str:
|
|
||||||
if self.platform == "opencode":
|
|
||||||
return "opencode"
|
|
||||||
elif self.platform == "gemini":
|
|
||||||
return "gemini"
|
|
||||||
else:
|
|
||||||
return "claude"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Prevention**: When adding a new value to a Python `Literal` type, search for ALL if/elif/else chains that switch on that type and add explicit branches. Don't rely on `else` being correct for new values.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gotcha: Asymmetric Mechanisms Producing Same Output
|
|
||||||
|
|
||||||
**Problem**: When two different mechanisms must produce the same file set (e.g., recursive directory copy for init vs. manual `files.set()` for update), structural changes (renaming, moving, adding subdirectories) only propagate through the automatic mechanism. The manual one silently drifts.
|
|
||||||
|
|
||||||
**Symptom**: Init works perfectly, but update creates files at wrong paths or misses files entirely.
|
|
||||||
|
|
||||||
**Prevention**:
|
|
||||||
- **Best**: Eliminate the asymmetry — have the manual path call the automatic one (e.g., `collectTemplateFiles()` calls `getAllScripts()` instead of maintaining its own list)
|
|
||||||
- **If asymmetry is unavoidable**: Add a regression test that compares outputs from both mechanisms
|
|
||||||
- When migrating directory structures, search for ALL code paths that reference the old structure
|
|
||||||
|
|
||||||
**Real example**: `trellis update` had a manual `files.set()` list for 11 scripts that `getAllScripts()` already tracked. Fix: replaced the manual list with a `for..of getAllScripts()` loop. See `update.ts` refactor in v0.4.0-beta.3.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Template File Registration (Trellis-specific)
|
|
||||||
|
|
||||||
When adding new files to `src/templates/trellis/scripts/`:
|
|
||||||
|
|
||||||
**Single registration point**: `src/templates/trellis/index.ts`
|
|
||||||
|
|
||||||
1. Add `export const xxxScript = readTemplate("scripts/path/file.py");`
|
|
||||||
2. Add to `getAllScripts()` Map
|
|
||||||
|
|
||||||
That's it. `commands/update.ts` uses `getAllScripts()` directly — no manual sync needed.
|
|
||||||
|
|
||||||
**Why this matters**: Without registration in `getAllScripts()`, `trellis update` won't sync the file to user projects. Bug fixes and features won't propagate.
|
|
||||||
|
|
||||||
**History**: Before v0.4.0-beta.3, `update.ts` had its own hand-maintained file list that frequently fell out of sync with `getAllScripts()`. This caused 11 Python files to be silently skipped during `trellis update`. The fix was to eliminate the duplicate list and use `getAllScripts()` as the single source of truth.
|
|
||||||
|
|
||||||
### Quick Checklist for New Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# After adding a new .py file, verify it's in getAllScripts():
|
|
||||||
grep -l "newFileName" src/templates/trellis/index.ts # Should match
|
|
||||||
```
|
|
||||||
|
|
||||||
### Template Sync Convention
|
|
||||||
|
|
||||||
`.trellis/scripts/` (dogfooded) and `packages/cli/src/templates/trellis/scripts/` (template) must stay identical. After editing `.trellis/scripts/`, always sync:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
rsync -av --delete --exclude='__pycache__' .trellis/scripts/ packages/cli/src/templates/trellis/scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Gotcha**: Running rsync with wrong source/destination paths can create nested garbage directories (e.g., `.trellis/scripts/packages/cli/...`). Always double-check paths before running.
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
# Cross-Layer Thinking Guide
|
|
||||||
|
|
||||||
> **Purpose**: Think through data flow across layers before implementing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The Problem
|
|
||||||
|
|
||||||
**Most bugs happen at layer boundaries**, not within layers.
|
|
||||||
|
|
||||||
Common cross-layer bugs:
|
|
||||||
|
|
||||||
- API returns format A, frontend expects format B
|
|
||||||
- Database stores X, service transforms to Y, but loses data
|
|
||||||
- Multiple layers implement the same logic differently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Before Implementing Cross-Layer Features
|
|
||||||
|
|
||||||
### Step 1: Map the Data Flow
|
|
||||||
|
|
||||||
Draw out how data moves:
|
|
||||||
|
|
||||||
```
|
|
||||||
Source → Transform → Store → Retrieve → Transform → Display
|
|
||||||
```
|
|
||||||
|
|
||||||
For each arrow, ask:
|
|
||||||
|
|
||||||
- What format is the data in?
|
|
||||||
- What could go wrong?
|
|
||||||
- Who is responsible for validation?
|
|
||||||
|
|
||||||
### Step 2: Identify Boundaries
|
|
||||||
|
|
||||||
| Boundary | Common Issues |
|
|
||||||
| --------------------- | --------------------------------- |
|
|
||||||
| API ↔ Service | Type mismatches, missing fields |
|
|
||||||
| Service ↔ Database | Format conversions, null handling |
|
|
||||||
| Backend ↔ Frontend | Serialization, date formats |
|
|
||||||
| Component ↔ Component | Props shape changes |
|
|
||||||
|
|
||||||
### Step 3: Define Contracts
|
|
||||||
|
|
||||||
For each boundary:
|
|
||||||
|
|
||||||
- What is the exact input format?
|
|
||||||
- What is the exact output format?
|
|
||||||
- What errors can occur?
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Common Cross-Layer Mistakes
|
|
||||||
|
|
||||||
### Mistake 1: Implicit Format Assumptions
|
|
||||||
|
|
||||||
**Bad**: Assuming date format without checking
|
|
||||||
|
|
||||||
**Good**: Explicit format conversion at boundaries
|
|
||||||
|
|
||||||
### Mistake 2: Scattered Validation
|
|
||||||
|
|
||||||
**Bad**: Validating the same thing in multiple layers
|
|
||||||
|
|
||||||
**Good**: Validate once at the entry point
|
|
||||||
|
|
||||||
### Mistake 3: Leaky Abstractions
|
|
||||||
|
|
||||||
**Bad**: Component knows about database schema
|
|
||||||
|
|
||||||
**Good**: Each layer only knows its neighbors
|
|
||||||
|
|
||||||
### Mistake 4: Every Consumer Parses The Same Payload
|
|
||||||
|
|
||||||
**Bad**: A command reads JSONL events and casts fields inline:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const thread = (ev as { thread?: string }).thread;
|
|
||||||
const labels = (ev as { labels?: string[] }).labels;
|
|
||||||
```
|
|
||||||
|
|
||||||
This looks local, but it means every consumer owns a private version of the
|
|
||||||
event contract. The next field change will update one command and miss another.
|
|
||||||
|
|
||||||
**Good**: Decode once at the event boundary, then export typed projections:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (!isThreadEvent(ev)) return false;
|
|
||||||
return ev.thread === filter.thread;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Rule**: For append-only logs, JSON streams, RPC payloads, or config files,
|
|
||||||
create one owner for:
|
|
||||||
|
|
||||||
- event / payload type definitions
|
|
||||||
- type guards and normalization from `unknown`
|
|
||||||
- metadata projections used by UI commands
|
|
||||||
- reducers that replay state from the source of truth
|
|
||||||
|
|
||||||
Rendering code may format fields, but it must not redefine the payload contract.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Checklist for Cross-Layer Features
|
|
||||||
|
|
||||||
Before implementation:
|
|
||||||
|
|
||||||
- [ ] Mapped the complete data flow
|
|
||||||
- [ ] Identified all layer boundaries
|
|
||||||
- [ ] Defined format at each boundary
|
|
||||||
- [ ] Decided where validation happens
|
|
||||||
|
|
||||||
After implementation:
|
|
||||||
|
|
||||||
- [ ] Tested with edge cases (null, empty, invalid)
|
|
||||||
- [ ] Verified error handling at each boundary
|
|
||||||
- [ ] Checked data survives round-trip
|
|
||||||
- [ ] Checked that consumers import shared decoders / projections instead of
|
|
||||||
casting payload fields locally
|
|
||||||
- [ ] Checked that derived state points back to the source event identifier
|
|
||||||
(`seq`, `id`, `version`) instead of inventing a second cursor
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-Platform Template Consistency
|
|
||||||
|
|
||||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
|
||||||
|
|
||||||
### Checklist: After Modifying Any Command Template
|
|
||||||
|
|
||||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
|
||||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
|
||||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
|
||||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
|
||||||
|
|
||||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generated Runtime Template Upgrade Consistency
|
|
||||||
|
|
||||||
Some generated files are both documentation and runtime input. In Trellis,
|
|
||||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
|
||||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
|
||||||
against both fresh init and upgrade paths.
|
|
||||||
|
|
||||||
### Checklist: After Modifying A Runtime-Parsed Template
|
|
||||||
|
|
||||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
|
||||||
writer that installs it
|
|
||||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
|
||||||
such as tag blocks
|
|
||||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
|
||||||
the older `.trellis/.version`
|
|
||||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
|
||||||
assert the installed file reaches the current packaged shape
|
|
||||||
- [ ] Update the backend spec that owns the runtime contract
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Versioned Documentation Boundary
|
|
||||||
|
|
||||||
Versioned documentation is a cross-layer boundary: source paths, `docs.json`
|
|
||||||
version routing, and the rendered version selector must all describe the same
|
|
||||||
release line.
|
|
||||||
|
|
||||||
### Checklist: Before Editing Versioned Docs
|
|
||||||
|
|
||||||
- [ ] Identify the target release line: stable, beta, or RC
|
|
||||||
- [ ] Verify the edited MDX path matches that line:
|
|
||||||
- stable: `docs-site/{start,advanced,...}` and `docs-site/zh/{start,advanced,...}`
|
|
||||||
- beta: `docs-site/beta/**` and `docs-site/zh/beta/**`
|
|
||||||
- RC: `docs-site/rc/**` and `docs-site/zh/rc/**`
|
|
||||||
- [ ] Verify `docs.json` navigation points the version label to the same paths
|
|
||||||
- [ ] Grep the opposite tree for release-line-specific terms before committing
|
|
||||||
- [ ] Treat beta content appearing under root release paths as a source-path bug,
|
|
||||||
not a rendering bug
|
|
||||||
|
|
||||||
**Real-world example**: A beta-only task workflow change documented
|
|
||||||
`prd.md` + `design.md` + `implement.md`, task-creation consent, and Codex
|
|
||||||
mode banners under root `start/` and `advanced/` paths. The docs site then
|
|
||||||
served 0.6 beta behavior under the Release selector. The fix was to restore root
|
|
||||||
release docs, move the 0.6 content to `beta/` and `zh/beta/`, and add a grep
|
|
||||||
audit for beta markers against the root release tree.
|
|
||||||
|
|
||||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
|
||||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
|
||||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
|
||||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
|
||||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
|
||||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
|
||||||
could return empty Phase 2.1 detail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mode-Detection Probe Checklist
|
|
||||||
|
|
||||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
|
||||||
|
|
||||||
### Before implementing:
|
|
||||||
|
|
||||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
|
||||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
|
||||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
|
||||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
|
||||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
|
||||||
|
|
||||||
### After implementing:
|
|
||||||
|
|
||||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
|
||||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
|
||||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
|
||||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
|
||||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
|
||||||
|
|
||||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
|
||||||
|
|
||||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cross-Platform Template Consistency
|
|
||||||
|
|
||||||
In Trellis, command templates (e.g., `record-session.md`) exist in **multiple platforms** with identical or near-identical content. This is a cross-layer boundary.
|
|
||||||
|
|
||||||
### Checklist: After Modifying Any Command Template
|
|
||||||
|
|
||||||
- [ ] Find all platforms with the same command: `find src/templates/*/commands/trellis/ -name "<command>.*"`
|
|
||||||
- [ ] Update all platform copies (Markdown `.md` and TOML `.toml`)
|
|
||||||
- [ ] For Gemini TOML: adapt line continuations (`\\` vs `\`) and triple-quoted strings
|
|
||||||
- [ ] Run `/trellis:check-cross-layer` to verify nothing was missed
|
|
||||||
|
|
||||||
**Real-world example**: Updated `record-session.md` in Claude to use `--mode record`, but forgot iFlow, Kilo, OpenCode, and Gemini — caught by cross-layer check.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Generated Runtime Template Upgrade Consistency
|
|
||||||
|
|
||||||
Some generated files are both documentation and runtime input. In Trellis,
|
|
||||||
`.trellis/workflow.md` is parsed by `get_context.py`, `workflow_phase.py`,
|
|
||||||
SessionStart filters, and per-turn hooks. Template changes must be validated
|
|
||||||
against both fresh init and upgrade paths.
|
|
||||||
|
|
||||||
### Checklist: After Modifying A Runtime-Parsed Template
|
|
||||||
|
|
||||||
- [ ] Identify every runtime parser that reads the template, not just the file
|
|
||||||
writer that installs it
|
|
||||||
- [ ] Check whether relevant syntax lives outside obvious managed regions
|
|
||||||
such as tag blocks
|
|
||||||
- [ ] Verify fresh `init` output and a versioned `update` scenario that writes
|
|
||||||
the older `.trellis/.version`
|
|
||||||
- [ ] Add an upgrade regression using an older pristine template fixture, then
|
|
||||||
assert the installed file reaches the current packaged shape
|
|
||||||
- [ ] Update the backend spec that owns the runtime contract
|
|
||||||
|
|
||||||
**Real-world example**: Codex inline mode changed workflow platform markers from
|
|
||||||
`[Codex]` / `[Kilo, Antigravity, Windsurf]` to `[codex-sub-agent]` /
|
|
||||||
`[codex-inline, Kilo, Antigravity, Windsurf]`. Fresh init was correct, but
|
|
||||||
`trellis update` only merged `[workflow-state:*]` blocks and preserved stale
|
|
||||||
markers outside those blocks. Result: upgraded projects got new hook scripts
|
|
||||||
but old workflow routing, so `get_context.py --mode phase --platform codex`
|
|
||||||
could return empty Phase 2.1 detail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mode-Detection Probe Checklist
|
|
||||||
|
|
||||||
When a CLI auto-detects a mode by probing a remote resource (e.g., checking if `index.json` exists to decide marketplace vs direct download):
|
|
||||||
|
|
||||||
### Before implementing:
|
|
||||||
- [ ] Probe runs in **ALL** code paths that use the result (interactive, `-y`, `--flag` combos)
|
|
||||||
- [ ] 404 vs transient error are distinguished — don't treat both as "not found"
|
|
||||||
- [ ] Transient errors **abort or retry**, never silently switch modes
|
|
||||||
- [ ] Shared state (caches, prefetched data) is **reset** when context changes (e.g., user switches source)
|
|
||||||
- [ ] **Shortcut paths** (e.g., `--template` skipping picker) must have the same error-handling quality as the probed path — check that downstream functions don't call catch-all wrappers
|
|
||||||
|
|
||||||
### After implementing:
|
|
||||||
- [ ] Trace every path from probe result to the mode-decision branch — no fallthrough
|
|
||||||
- [ ] External format contracts (giget URI, raw URLs) are tested or at least documented as comments
|
|
||||||
- [ ] Metadata reads consume a complete response or use a streaming parser — never parse a fixed-size prefix as full JSON
|
|
||||||
- [ ] When reconstructing a composite identifier from parsed parts, verify **all** fields are included and in the **correct position** (e.g., `provider:repo/path#ref` not `provider:repo#ref/path`)
|
|
||||||
- [ ] Verify that **action functions** called after a shortcut don't internally use the old catch-all fetch — they must use the probe-quality variant when error distinction matters
|
|
||||||
|
|
||||||
**Real-world example**: Custom registry flow had 8 bugs across 3 review rounds: (1) probe only ran in interactive mode, (2) transient errors fell through to wrong mode, (3) giget URI had `#ref` in wrong position, (4) prefetched templates leaked across source switches, (5) `--template` shortcut bypassed probe but `downloadTemplateById` internally used catch-all `fetchTemplateIndex`, turning timeouts into "Template not found".
|
|
||||||
|
|
||||||
**Real-world example**: Agent-session update hints fetched npm `latest` metadata with `response.read(4096)` and then parsed it as complete JSON. The `@mindfoldhq/trellis` package metadata exceeded 4 KB, so the JSON was truncated, parse failed silently, and the first session injection showed no update hint. Fix: read the complete response before parsing, and add a regression where `version` is followed by an 8 KB metadata tail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## When to Create Flow Documentation
|
|
||||||
|
|
||||||
Create detailed flow docs when:
|
|
||||||
|
|
||||||
- Feature spans 3+ layers
|
|
||||||
- Multiple teams are involved
|
|
||||||
- Data format is complex
|
|
||||||
- Feature has caused bugs before
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Event Log / Projection Boundary
|
|
||||||
|
|
||||||
Append-only logs are cross-layer contracts. A single event travels through:
|
|
||||||
|
|
||||||
```
|
|
||||||
CLI input → event writer → events.jsonl → reader → filter → reducer → display
|
|
||||||
```
|
|
||||||
|
|
||||||
### Checklist: After Adding A New Event Kind Or Field
|
|
||||||
|
|
||||||
- [ ] Add the event kind to the central event taxonomy
|
|
||||||
- [ ] Add a typed event variant or type guard at the event layer
|
|
||||||
- [ ] Add normalization helpers for array/object fields that come from
|
|
||||||
user input or JSON
|
|
||||||
- [ ] Keep `seq` / `id` assignment in the event writer only
|
|
||||||
- [ ] Make filters and reducers consume the typed event guard, not local casts
|
|
||||||
- [ ] Make display code consume reducer output or typed events, not raw JSON
|
|
||||||
- [ ] Add at least one regression that proves history replay and live filtering
|
|
||||||
use the same filter model
|
|
||||||
|
|
||||||
**Real-world example**: Thread channels added `kind: "thread"`, `description`,
|
|
||||||
`context`, labels, and `lastSeq`. The first implementation replayed thread
|
|
||||||
state correctly, but several commands still re-parsed event payload fields with
|
|
||||||
local casts. The fix was to make the core event layer own `ThreadChannelEvent`
|
|
||||||
and `isThreadEvent`, make `reduceChannelMetadata` the only channel metadata
|
|
||||||
projection, and make `reduceThreads` the only thread replay reducer.
|
|
||||||
@@ -1,98 +1,24 @@
|
|||||||
# Thinking Guides
|
# 项目 Guides
|
||||||
|
|
||||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
> 跨仓库的工作纪律与流程约定;各层编码规范见 `oci-portal/backend/` 与 `oci-portal-dash/frontend/`。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why Thinking Guides?
|
|
||||||
|
|
||||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
|
||||||
|
|
||||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
|
||||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
|
||||||
- Didn't think about edge cases → runtime errors
|
|
||||||
- Didn't think about future maintainers → unreadable code
|
|
||||||
|
|
||||||
These guides help you **ask the right questions before coding**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Available Guides
|
## Available Guides
|
||||||
|
|
||||||
| Guide | Purpose | When to Use |
|
| Guide | 内容 | 何时读 |
|
||||||
|-------|---------|-------------|
|
|-------|------|--------|
|
||||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
|
||||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
|
||||||
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 |
|
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 |
|
||||||
|
|
||||||
---
|
## AI 交叉评审结果核验
|
||||||
|
|
||||||
## Quick Reference: Thinking Triggers
|
- 评审称「用户输入可能是恶意的」→ 先查实际数据源:内部清单?用户配置?外部 API?
|
||||||
|
- 评审标「缺少校验」→ 数据是否来自可信内部源?
|
||||||
|
- 评审称「行为变化」→ 读代码注释——是否本就是有意设计?
|
||||||
|
- 评审指测试有 bug → 心里删掉被测功能,测试还过吗?过 = 同义反复测试
|
||||||
|
|
||||||
### When to Think About Cross-Layer Issues
|
常见误报模式:信任边界混淆(把内置数据当外部输入)、无视设计注释、变量误读(未追到实际定义)。
|
||||||
|
|
||||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
**核验规则**:每条 CRITICAL/WARNING 结论必须对照实际代码核验后再排优先级;AI 评审按 ~35% 误报率做预算。
|
||||||
- [ ] Data format changes between layers
|
|
||||||
- [ ] Multiple consumers need the same data
|
|
||||||
- [ ] You're not sure where to put some logic
|
|
||||||
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
|
||||||
- [ ] UI / command code starts casting raw payload fields directly
|
|
||||||
|
|
||||||
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
|
|
||||||
|
|
||||||
### When to Think About Code Reuse
|
|
||||||
|
|
||||||
- [ ] You're writing similar code to something that exists
|
|
||||||
- [ ] You see the same pattern repeated 3+ times
|
|
||||||
- [ ] You're adding a new field to multiple places
|
|
||||||
- [ ] **You're modifying any constant or config**
|
|
||||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
|
||||||
- [ ] Two files read the same untyped payload field with local casts
|
|
||||||
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
|
||||||
|
|
||||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
|
||||||
|
|
||||||
### When Verifying AI Cross-Review Results
|
|
||||||
|
|
||||||
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
|
||||||
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
|
||||||
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
|
||||||
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
|
||||||
|
|
||||||
**Common AI reviewer false-positive patterns**:
|
|
||||||
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
|
||||||
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
|
||||||
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
|
||||||
|
|
||||||
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Pre-Modification Rule (CRITICAL)
|
2026-07 已删除上游模板自带的两个通用 thinking guide(code-reuse / cross-layer):内容为通用常识与 Trellis 自身开发案例,与本项目无关。项目教训一律直接沉淀到对应层的规范文件,不建通用思维文档。
|
||||||
|
|
||||||
> **Before changing ANY value, ALWAYS search first!**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Search for the value you're about to change
|
|
||||||
grep -r "value_to_change" .
|
|
||||||
```
|
|
||||||
|
|
||||||
This single habit prevents most "forgot to update X" bugs.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Use This Directory
|
|
||||||
|
|
||||||
1. **Before coding**: Skim the relevant thinking guide
|
|
||||||
2. **During coding**: If something feels repetitive or complex, check the guides
|
|
||||||
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Found a new "didn't think of that" moment? Add it to the relevant guide.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
|
||||||
|
|||||||
+20
-19
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,52 @@
|
|||||||
|
|
||||||
格式参考 [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]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- SAML IdP 创建支持将不超过 1 MB 的 Logo 上传到身份域公共图片存储并预览;替换或放弃表单时会尽力清理未采用图标
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 对象存储 PAR 分享链接有效期上限由 30 天扩展至 100 年,并新增 1 年 / 10 年快捷选项
|
||||||
|
- 总览成本改为按币种分开展示:主币种保留趋势图,其他币种单列合计,不再跨币种相加
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 修复 OAuth 绑定回跳与会话过期处理:优先接收新令牌,旧请求迟到的 401 不再清除新会话;当前会话失效时保留原路径跳转登录
|
||||||
|
- 修复安全设置与 OAuth 登录方式并发保存时互相回滚或复活已删除配置,改为字段级更新并合并在途修改
|
||||||
|
- 修复对象存储切换租户 / 区域 / 桶后仍残留旧路径、游标、选中项及 PAR 列表,避免对同名资源执行错误操作;快速切换对象时旧内容 / ETag 不再覆盖当前预览
|
||||||
|
- 修复未知大小及大文件下载因异步打开窗口被浏览器拦截却仍显示成功;现预开下载窗口,拦截时明确提示并支持重试
|
||||||
|
- 修复快速切换实例时旧串行控制台会话接入当前面板,以及日志翻页 / 自动刷新 / 连续查看详情时迟到响应覆盖当前数据
|
||||||
|
- 修复抢机任务编辑时把剩余台数误作目标台数,并避免慢接口下任务列表轮询请求重叠
|
||||||
|
- 修复创建 SAML IdP 时身份设置未按所选身份域读取、异步响应串单和重复提交;JIT 后续配置状态不确定时改为显示关联请求警告
|
||||||
|
|
||||||
## [0.8.0]
|
## [0.8.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ oci-portal-dash:OCI Portal 前端(Vue 3 + Vite + TypeScript + Naive UI + Tai
|
|||||||
## 质量门(提交前必过)
|
## 质量门(提交前必过)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint && npm run typecheck && npm run build
|
npm run lint && npm run typecheck && npm run test && npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## 文档同步
|
## 文档同步
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ npm run dev # http://localhost:5173,默认 mock 模式开箱可视
|
|||||||
```bash
|
```bash
|
||||||
npm run lint # ESLint + Prettier(带 --fix)
|
npm run lint # ESLint + Prettier(带 --fix)
|
||||||
npm run typecheck # vue-tsc --noEmit
|
npm run typecheck # vue-tsc --noEmit
|
||||||
|
npm run test # vitest 单元测试(happy-dom 环境)
|
||||||
npm run build # 类型检查 + 产物构建到 dist/
|
npm run build # 类型检查 + 产物构建到 dist/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+466
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oci-portal-dash",
|
"name": "oci-portal-dash",
|
||||||
"version": "0.2.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.2.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",
|
||||||
@@ -50,11 +51,13 @@
|
|||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
|
"happy-dom": "^20.11.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "~5.7.3",
|
"typescript": "~5.7.3",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -3412,6 +3415,19 @@
|
|||||||
"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": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.3.2",
|
"version": "4.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
|
||||||
@@ -3719,6 +3735,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -3772,6 +3806,23 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/whatwg-mimetype": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.62.1",
|
"version": "8.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz",
|
||||||
@@ -4029,6 +4080,129 @@
|
|||||||
"vue": "^3.2.25"
|
"vue": "^3.2.25"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker/node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@volar/language-core": {
|
"node_modules/@volar/language-core": {
|
||||||
"version": "2.4.15",
|
"version": "2.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz",
|
||||||
@@ -4497,6 +4671,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
@@ -4695,6 +4879,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-image-size": {
|
||||||
|
"version": "0.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
|
||||||
|
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -4776,6 +4973,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -5300,6 +5507,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
@@ -5797,6 +6011,16 @@
|
|||||||
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
|
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -6266,6 +6490,25 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/happy-dom": {
|
||||||
|
"version": "20.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.0.tgz",
|
||||||
|
"integrity": "sha512-XogN4asPd1a56di9prVG6bZxteNcXsZxxKmAvcEfc5Px5Ca2hMyMgk8wvqK2K1V8zXg40j9VANXsDaJYh9DeNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": ">=20.0.0",
|
||||||
|
"@types/whatwg-mimetype": "^3.0.2",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"buffer-image-size": "^0.6.4",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"whatwg-mimetype": "^3.0.0",
|
||||||
|
"ws": "^8.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -7631,6 +7874,20 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -7780,6 +8037,13 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8417,6 +8681,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/signal-exit": {
|
"node_modules/signal-exit": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||||
@@ -8480,6 +8751,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -8750,6 +9035,23 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -8798,6 +9100,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -9279,6 +9591,109 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.10",
|
||||||
|
"@vitest/mocker": "4.1.10",
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"@vitest/runner": "4.1.10",
|
||||||
|
"@vitest/snapshot": "4.1.10",
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.10",
|
||||||
|
"@vitest/browser-preview": "4.1.10",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.10",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.10",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
|
"@vitest/ui": "4.1.10",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vooks": {
|
"node_modules/vooks": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
|
||||||
@@ -9448,6 +9863,16 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -9553,6 +9978,23 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -9824,6 +10266,28 @@
|
|||||||
"workbox-core": "7.4.1"
|
"workbox-core": "7.4.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
|
||||||
|
|||||||
+5
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "oci-portal-dash",
|
"name": "oci-portal-dash",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.8.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
|
"test": "vitest run",
|
||||||
"format": "prettier --write src/"
|
"format": "prettier --write src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -25,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",
|
||||||
@@ -54,11 +56,13 @@
|
|||||||
"@vue/tsconfig": "^0.7.0",
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
|
"happy-dom": "^20.11.0",
|
||||||
"prettier": "^3.9.5",
|
"prettier": "^3.9.5",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"typescript": "~5.7.3",
|
"typescript": "~5.7.3",
|
||||||
"vite": "^6.0.7",
|
"vite": "^6.0.7",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
|
"vitest": "^4.1.10",
|
||||||
"vue-tsc": "^2.2.0"
|
"vue-tsc": "^2.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+174
-24
@@ -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,14 +176,113 @@ 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 配置(设置页) ----
|
||||||
|
|
||||||
export function getOAuthSettings(): Promise<OAuthSettings> {
|
const mockOauth: OAuthSettings = {
|
||||||
if (mockOn)
|
|
||||||
return mocked({
|
|
||||||
oidcIssuer: '',
|
oidcIssuer: '',
|
||||||
oidcClientId: '',
|
oidcClientId: '',
|
||||||
oidcSecretSet: false,
|
oidcSecretSet: false,
|
||||||
@@ -141,22 +292,21 @@ export function getOAuthSettings(): Promise<OAuthSettings> {
|
|||||||
githubSecretSet: false,
|
githubSecretSet: false,
|
||||||
githubDisplayName: '',
|
githubDisplayName: '',
|
||||||
githubDisabled: false,
|
githubDisabled: false,
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export function getOAuthSettings(): Promise<OAuthSettings> {
|
||||||
|
if (mockOn) return mocked({ ...mockOauth })
|
||||||
return request('/settings/oauth')
|
return request('/settings/oauth')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
|
||||||
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
|
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
|
||||||
if (mockOn)
|
if (mockOn) {
|
||||||
return mocked({
|
const { oidcClientSecret, githubClientSecret, ...rest } = body
|
||||||
oidcIssuer: body.oidcIssuer,
|
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
|
||||||
oidcClientId: body.oidcClientId,
|
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
|
||||||
oidcSecretSet: !!body.oidcClientSecret,
|
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
|
||||||
oidcDisplayName: body.oidcDisplayName,
|
return mocked({ ...mockOauth })
|
||||||
oidcDisabled: body.oidcDisabled,
|
}
|
||||||
githubClientId: body.githubClientId,
|
return request('/settings/oauth', { method: 'PATCH', body })
|
||||||
githubSecretSet: !!body.githubClientSecret,
|
|
||||||
githubDisplayName: body.githubDisplayName,
|
|
||||||
githubDisabled: body.githubDisabled,
|
|
||||||
})
|
|
||||||
return request('/settings/oauth', { method: 'PUT', body })
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 },
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { request, ApiError } from './request'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
/** happy-dom 提供的当前页 URL 控制入口(标准 DOM 无此能力) */
|
||||||
|
function setPageURL(url: string) {
|
||||||
|
;(window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造最小 401 响应;rotate 在响应返回前换发新会话,模拟 OAuth 绑定回跳 */
|
||||||
|
function stub401(rotateTo?: string) {
|
||||||
|
vi.stubGlobal('fetch', async () => {
|
||||||
|
if (rotateTo !== undefined) useAuthStore().setSession(rotateTo, '')
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
url: '/api/v1/x',
|
||||||
|
json: async () => ({ error: '未认证' }),
|
||||||
|
} as unknown as Response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubRefreshThen401() {
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||||
|
vi.stubGlobal('fetch', async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).endsWith('/refresh')) {
|
||||||
|
await gate
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
json: async () => ({ error: '令牌已失效' }),
|
||||||
|
} as unknown as Response
|
||||||
|
})
|
||||||
|
return release
|
||||||
|
}
|
||||||
|
|
||||||
|
let assignSpy: ReturnType<typeof vi.spyOn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
assignSpy = vi.spyOn(location, 'assign').mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 可控完成时机的 fetch stub,记录调用次数 */
|
||||||
|
function stubSlowFetch() {
|
||||||
|
const calls = { count: 0 }
|
||||||
|
let release!: () => void
|
||||||
|
const gate = new Promise<void>((r) => (release = r))
|
||||||
|
vi.stubGlobal('fetch', async () => {
|
||||||
|
calls.count++
|
||||||
|
await gate
|
||||||
|
return { ok: true, status: 200, text: async () => '{"ok":true}' } as unknown as Response
|
||||||
|
})
|
||||||
|
return { calls, release }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('写请求 in-flight 去重', () => {
|
||||||
|
it('并发相同写请求只发一次,共享同一结果', async () => {
|
||||||
|
const { calls, release } = stubSlowFetch()
|
||||||
|
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
|
||||||
|
const p2 = request('/y', { method: 'POST', body: { a: 1 } })
|
||||||
|
release()
|
||||||
|
expect(await Promise.all([p1, p2])).toEqual([{ ok: true }, { ok: true }])
|
||||||
|
expect(calls.count).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('body 不同不去重;完成后同 key 可再发', async () => {
|
||||||
|
const { calls, release } = stubSlowFetch()
|
||||||
|
const p1 = request('/y', { method: 'POST', body: { a: 1 } })
|
||||||
|
const p2 = request('/y', { method: 'POST', body: { a: 2 } })
|
||||||
|
release()
|
||||||
|
await Promise.all([p1, p2])
|
||||||
|
expect(calls.count).toBe(2)
|
||||||
|
await request('/y', { method: 'POST', body: { a: 1 } })
|
||||||
|
expect(calls.count).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GET 不去重', async () => {
|
||||||
|
const { calls, release } = stubSlowFetch()
|
||||||
|
const p1 = request('/y')
|
||||||
|
const p2 = request('/y')
|
||||||
|
release()
|
||||||
|
await Promise.all([p1, p2])
|
||||||
|
expect(calls.count).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 构造 429 响应;body 由调用方给定(带不带 code 决定分流) */
|
||||||
|
function stub429(body: Record<string, unknown>) {
|
||||||
|
vi.stubGlobal('fetch', async () => {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
url: '/api/v1/x',
|
||||||
|
json: async () => body,
|
||||||
|
} as unknown as Response
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('429 分流', () => {
|
||||||
|
it('全局 IP 限流(code=RateLimited):整页跳转 /blocked', async () => {
|
||||||
|
setPageURL('http://localhost/instances')
|
||||||
|
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
|
||||||
|
await expect(request('/x')).rejects.toMatchObject({ status: 429 })
|
||||||
|
expect(assignSpy).toHaveBeenCalledWith('/blocked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('登录守卫锁定(无 code):不跳转,留在表单内提示', async () => {
|
||||||
|
setPageURL('http://localhost/login')
|
||||||
|
stub429({ error: '尝试过于频繁,请稍后再试' })
|
||||||
|
await expect(request('/auth/wallet/verify', { method: 'POST', body: {} })).rejects.toMatchObject(
|
||||||
|
{ status: 429, message: '尝试过于频繁,请稍后再试' },
|
||||||
|
)
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('设置页绑定遇全局限流:同样进入 /blocked(不按 URL 判断)', async () => {
|
||||||
|
setPageURL('http://localhost/settings')
|
||||||
|
stub429({ error: 'rate limit exceeded', code: 'RateLimited' })
|
||||||
|
await expect(
|
||||||
|
request('/auth/wallet/challenge', { method: 'POST', body: {} }),
|
||||||
|
).rejects.toMatchObject({ status: 429 })
|
||||||
|
expect(assignSpy).toHaveBeenCalledWith('/blocked')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('401 令牌快照', () => {
|
||||||
|
it('当前 token 收到 401:登出并带 redirect 跳登录', async () => {
|
||||||
|
setPageURL('http://localhost/instances?tab=list')
|
||||||
|
useAuthStore().setSession('tokA', '')
|
||||||
|
stub401()
|
||||||
|
await expect(request('/x')).rejects.toMatchObject({ status: 401, message: '未认证' })
|
||||||
|
expect(useAuthStore().token).toBe('')
|
||||||
|
expect(assignSpy).toHaveBeenCalledWith(
|
||||||
|
`/login?redirect=${encodeURIComponent('/instances?tab=list')}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('响应到达前会话已换新:旧 token 的迟到 401 不得清掉新会话', async () => {
|
||||||
|
setPageURL('http://localhost/settings')
|
||||||
|
useAuthStore().setSession('tokA', '')
|
||||||
|
stub401('tokB')
|
||||||
|
await expect(request('/x')).rejects.toBeInstanceOf(ApiError)
|
||||||
|
expect(useAuthStore().token).toBe('tokB')
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('401 先到时等待并发换新响应落入 Store,不得提前登出', async () => {
|
||||||
|
setPageURL('http://localhost/settings')
|
||||||
|
useAuthStore().setSession('tokA', '')
|
||||||
|
const releaseRefresh = stubRefreshThen401()
|
||||||
|
const refresh = request<{ token: string; expiresAt: string }>('/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
refreshesSession: true,
|
||||||
|
})
|
||||||
|
const stale = request('/stale', { method: 'POST' }).catch((error: unknown) => error)
|
||||||
|
await Promise.resolve()
|
||||||
|
expect(useAuthStore().token).toBe('tokA')
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled()
|
||||||
|
releaseRefresh()
|
||||||
|
const next = await refresh
|
||||||
|
expect(next.token).toBe('tokB')
|
||||||
|
expect(await stale).toBeInstanceOf(ApiError)
|
||||||
|
expect(useAuthStore().token).toBe('tokB')
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('换发 Promise 完成时已先写 Store,不给迟到 401 留微任务窗口', async () => {
|
||||||
|
setPageURL('http://localhost/settings')
|
||||||
|
useAuthStore().setSession('tokA', '')
|
||||||
|
vi.stubGlobal('fetch', async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
|
||||||
|
} as unknown as Response
|
||||||
|
})
|
||||||
|
await request('/refresh', { method: 'POST', refreshesSession: true })
|
||||||
|
expect(useAuthStore().token).toBe('tokB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('公开登录没有旧 token 时仍由页面接管新会话', async () => {
|
||||||
|
vi.stubGlobal('fetch', async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
text: async () => '{"token":"tokB","expiresAt":"2099-01-01T00:00:00Z"}',
|
||||||
|
} as unknown as Response
|
||||||
|
})
|
||||||
|
await request('/login', { method: 'POST', refreshesSession: true })
|
||||||
|
expect(useAuthStore().token).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('并发换新请求都返回 401 时不得互相等待', async () => {
|
||||||
|
setPageURL('http://localhost/settings')
|
||||||
|
useAuthStore().setSession('tokA', '')
|
||||||
|
stub401()
|
||||||
|
const opts = { method: 'POST', refreshesSession: true } as const
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
request('/refresh-a', opts),
|
||||||
|
request('/refresh-b', opts),
|
||||||
|
])
|
||||||
|
expect(results.map((result) => result.status)).toEqual(['rejected', 'rejected'])
|
||||||
|
expect(useAuthStore().token).toBe('')
|
||||||
|
expect(assignSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('登录页密码错误的 401:不跳转,错误留在表单内提示', async () => {
|
||||||
|
setPageURL('http://localhost/login')
|
||||||
|
stub401()
|
||||||
|
await expect(request('/auth/login', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||||
|
message: '未认证',
|
||||||
|
})
|
||||||
|
expect(assignSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
+140
-23
@@ -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,50 +51,147 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
|
|||||||
return s ? `${BASE}${path}?${s}` : BASE + path
|
return s ? `${BASE}${path}?${s}` : BASE + path
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseError(resp: Response): Promise<never> {
|
async function parseError(
|
||||||
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) useAuthStore().logout()
|
|
||||||
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
|
|
||||||
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
|
|
||||||
location.assign('/blocked')
|
|
||||||
}
|
|
||||||
throw new ApiError(resp.status, message, ociCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
|
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
|
||||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
|
||||||
|
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
|
||||||
|
async function handleUnauthorized(sentToken: string) {
|
||||||
|
const waited = await waitForSessionRefresh(sentToken)
|
||||||
|
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (sentToken !== auth.token) return
|
||||||
|
auth.logout()
|
||||||
|
if (location.pathname !== '/login') {
|
||||||
|
const redirect = encodeURIComponent(location.pathname + location.search)
|
||||||
|
location.assign(`/login?redirect=${redirect}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
|
||||||
|
const inflightWrites = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
|
function beginSessionRefresh(token: string) {
|
||||||
|
const state = sessionRefreshes.get(token)
|
||||||
|
if (state) {
|
||||||
|
state.count++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionRefreshes.set(token, { count: 1, waiters: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function endSessionRefresh(token: string) {
|
||||||
|
const state = sessionRefreshes.get(token)
|
||||||
|
if (!state) return
|
||||||
|
state.count--
|
||||||
|
state.waiters = state.waiters.filter((waiter) => {
|
||||||
|
if (state.count > 0) return true
|
||||||
|
waiter.resolve(true)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
if (state.count === 0) sessionRefreshes.delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForSessionRefresh(token: string): Promise<boolean> {
|
||||||
|
const state = sessionRefreshes.get(token)
|
||||||
|
if (!state || state.count === 0) return Promise.resolve(false)
|
||||||
|
return new Promise((resolve) => state.waiters.push({ resolve }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackSessionRefresh(token: string): () => void {
|
||||||
|
beginSessionRefresh(token)
|
||||||
|
let active = true
|
||||||
|
return () => {
|
||||||
|
if (!active) return
|
||||||
|
active = false
|
||||||
|
endSessionRefresh(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySessionRefresh(value: unknown) {
|
||||||
|
if (!value || typeof value !== 'object') return
|
||||||
|
const refresh = value as { token?: unknown; expiresAt?: unknown }
|
||||||
|
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
|
||||||
|
useAuthStore().setSession(refresh.token, refresh.expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
|
||||||
|
* 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise,
|
||||||
|
* 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。
|
||||||
|
* 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */
|
||||||
|
export function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
|
const method = (opts.method ?? 'GET').toUpperCase()
|
||||||
|
if (method === 'GET') return doRequest<T>(path, opts)
|
||||||
|
const body = opts.body === undefined ? '' : JSON.stringify(opts.body)
|
||||||
|
const sentToken = useAuthStore().token ?? ''
|
||||||
|
const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}`
|
||||||
|
const existing = inflightWrites.get(key)
|
||||||
|
if (existing) return existing as Promise<T>
|
||||||
|
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
|
||||||
|
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
|
||||||
|
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
|
||||||
|
inflightWrites.delete(key)
|
||||||
|
finishRefresh()
|
||||||
|
})
|
||||||
|
inflightWrites.set(key, p)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRequest<T>(
|
||||||
|
path: string,
|
||||||
|
opts: RequestOptions,
|
||||||
|
finishRefresh: () => void = () => {},
|
||||||
|
): Promise<T> {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
|
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
|
||||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||||
|
const sentToken = auth.token
|
||||||
const resp = await fetch(buildUrl(path, opts.query), {
|
const resp = await fetch(buildUrl(path, opts.query), {
|
||||||
method: opts.method ?? 'GET',
|
method: opts.method ?? 'GET',
|
||||||
headers,
|
headers,
|
||||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||||
})
|
})
|
||||||
if (!resp.ok) await parseError(resp)
|
if (!resp.ok) {
|
||||||
|
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(二进制内容读写用) */
|
||||||
@@ -91,12 +207,13 @@ export async function rawFetch(
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const headers: Record<string, string> = { ...opts.headers }
|
const headers: Record<string, string> = { ...opts.headers }
|
||||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||||
|
const sentToken = auth.token
|
||||||
const resp = await fetch(buildUrl(path, opts.query), {
|
const resp = await fetch(buildUrl(path, opts.query), {
|
||||||
method: opts.method ?? 'GET',
|
method: opts.method ?? 'GET',
|
||||||
headers,
|
headers,
|
||||||
body: opts.body,
|
body: opts.body,
|
||||||
})
|
})
|
||||||
if (!resp.ok) await parseError(resp)
|
if (!resp.ok) await parseError(resp, sentToken)
|
||||||
return resp
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
|
|||||||
return request('/settings/security')
|
return request('/settings/security')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */
|
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
|
||||||
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
|
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
|
||||||
if (mockOn) {
|
if (mockOn) {
|
||||||
Object.assign(mockSecuritySetting, body)
|
Object.assign(mockSecuritySetting, patch)
|
||||||
return mocked({ ...mockSecuritySetting }, 400)
|
return mocked({ ...mockSecuritySetting }, 400)
|
||||||
}
|
}
|
||||||
return request('/settings/security', { method: 'PUT', body })
|
return request('/settings/security', { method: 'PATCH', body: patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemLogParams {
|
export interface SystemLogParams {
|
||||||
|
|||||||
+2
-5
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
|
|||||||
return request(`/tasks/${id}/logs`, { query: { limit } })
|
return request(`/tasks/${id}/logs`, { query: { limit } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runTask(id: number): Promise<TaskLog> {
|
export function runTask(id: number): Promise<{ triggered: boolean }> {
|
||||||
if (mockOn) {
|
if (mockOn) return mocked({ triggered: true }, 300)
|
||||||
const logs = mockTaskLogs[id]
|
|
||||||
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
|
|
||||||
}
|
|
||||||
return request(`/tasks/${id}/run`, { method: 'POST' })
|
return request(`/tasks/${id}/run`, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-10
@@ -10,14 +10,16 @@ import {
|
|||||||
mockUserDetails,
|
mockUserDetails,
|
||||||
mockUsers,
|
mockUsers,
|
||||||
} from './mock'
|
} from './mock'
|
||||||
import { mockOn, mocked, request } from './request'
|
import { mockOn, mocked, rawFetch, request } from './request'
|
||||||
import type {
|
import type {
|
||||||
AuditEventsResult,
|
AuditEventsResult,
|
||||||
|
CreatedApiKey,
|
||||||
IamUser,
|
IamUser,
|
||||||
IdentityDomain,
|
IdentityDomain,
|
||||||
IamUserDetail,
|
IamUserDetail,
|
||||||
IdentityProvider,
|
IdentityProvider,
|
||||||
IdentitySetting,
|
IdentitySetting,
|
||||||
|
IdpIconUpload,
|
||||||
LimitItem,
|
LimitItem,
|
||||||
LimitService,
|
LimitService,
|
||||||
NotificationRecipients,
|
NotificationRecipients,
|
||||||
@@ -25,6 +27,7 @@ import type {
|
|||||||
SignOnRule,
|
SignOnRule,
|
||||||
SubscriptionDetail,
|
SubscriptionDetail,
|
||||||
SubscriptionSummary,
|
SubscriptionSummary,
|
||||||
|
UserApiKey,
|
||||||
} from '@/types/api'
|
} from '@/types/api'
|
||||||
|
|
||||||
// ---- 配额 ----
|
// ---- 配额 ----
|
||||||
@@ -168,15 +171,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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,7 +347,7 @@ export interface CreateIdpBody {
|
|||||||
name: string
|
name: string
|
||||||
metadata: string
|
metadata: string
|
||||||
description?: string
|
description?: string
|
||||||
/** logo:data URI 或外链 URL */
|
/** logo:http(s) 外链 URL(可先经 IdP 图标上传接口取得) */
|
||||||
iconUrl?: string
|
iconUrl?: string
|
||||||
nameIdFormat?: string
|
nameIdFormat?: string
|
||||||
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
|
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
|
||||||
@@ -325,6 +374,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
|
|||||||
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
|
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
|
||||||
|
export async function uploadIdpIcon(
|
||||||
|
id: number,
|
||||||
|
file: File,
|
||||||
|
domainId?: string,
|
||||||
|
): Promise<IdpIconUpload> {
|
||||||
|
if (mockOn)
|
||||||
|
return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600)
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file, file.name)
|
||||||
|
const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, {
|
||||||
|
method: 'POST',
|
||||||
|
query: { domainId },
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
return (await resp.json()) as IdpIconUpload
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除尚未被 IdP 采用的身份域公共图片 */
|
||||||
|
export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void> {
|
||||||
|
if (mockOn) return mocked(undefined)
|
||||||
|
return request(`/oci-configs/${id}/idp-icons`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
query: { domainId, fileName },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
|
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
|
||||||
if (mockOn) return mocked(mockIdps)
|
if (mockOn) return mocked(mockIdps)
|
||||||
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
|
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
|
||||||
|
|||||||
@@ -15,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 下,无
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
|
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: () => '删除' }) },
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -35,21 +35,34 @@ let ws: WebSocket | null = null
|
|||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
let sessionId = ''
|
let sessionId = ''
|
||||||
const encoder = new TextEncoder()
|
const encoder = new TextEncoder()
|
||||||
|
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
|
||||||
|
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
|
||||||
|
// B 实际连着 A,用户输入发往错误实例
|
||||||
|
let openSeq = 0
|
||||||
|
|
||||||
async function open() {
|
async function open() {
|
||||||
|
const seq = ++openSeq
|
||||||
phase.value = 'creating'
|
phase.value = 'creating'
|
||||||
error.value = ''
|
error.value = ''
|
||||||
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
||||||
|
let created = ''
|
||||||
try {
|
try {
|
||||||
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
||||||
sessionId = res.sessionId
|
created = res.sessionId
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (seq !== openSeq) return
|
||||||
phase.value = 'closed'
|
phase.value = 'closed'
|
||||||
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (seq !== openSeq) {
|
||||||
|
void deleteConsoleSession(created).catch(() => undefined)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionId = created
|
||||||
phase.value = 'connecting'
|
phase.value = 'connecting'
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
if (seq !== openSeq) return
|
||||||
mountTerm()
|
mountTerm()
|
||||||
connectWs()
|
connectWs()
|
||||||
}
|
}
|
||||||
@@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function teardown() {
|
function teardown() {
|
||||||
|
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
|
||||||
ro?.disconnect()
|
ro?.disconnect()
|
||||||
ro = null
|
ro = null
|
||||||
if (ws) {
|
if (ws) {
|
||||||
|
|||||||
@@ -41,16 +41,22 @@ const pagination = reactive({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||||
|
// 不得覆盖已切换的新页
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load(silent = false) {
|
async function load(silent = false) {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
|
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
|
||||||
|
if (seq !== loadSeq) return
|
||||||
rows.value = data.items
|
rows.value = data.items
|
||||||
pagination.itemCount = data.total
|
pagination.itemCount = data.total
|
||||||
} catch {
|
} catch {
|
||||||
// 自动刷新失败静默,手动路径由全局兜底
|
// 自动刷新失败静默,手动路径由全局兜底
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) loading.value = false
|
if (!silent && seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,12 +112,15 @@ const showDetail = ref(false)
|
|||||||
|
|
||||||
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
|
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
|
||||||
const contentLog = ref<AiContentLog | null>(null)
|
const contentLog = ref<AiContentLog | null>(null)
|
||||||
|
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
|
||||||
|
let contentSeq = 0
|
||||||
|
|
||||||
async function loadContent(callLogId: number) {
|
async function loadContent(callLogId: number) {
|
||||||
|
const seq = ++contentSeq
|
||||||
contentLog.value = null
|
contentLog.value = null
|
||||||
try {
|
try {
|
||||||
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
|
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
|
||||||
contentLog.value = items[0] ?? null
|
if (seq === contentSeq) contentLog.value = items[0] ?? null
|
||||||
} catch {
|
} catch {
|
||||||
// 正文加载失败不阻塞元数据详情
|
// 正文加载失败不阻塞元数据详情
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ const pagination = reactive({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||||
|
// 不得覆盖已切换的新页
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load(silent = false) {
|
async function load(silent = false) {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await listLogEvents({
|
const data = await listLogEvents({
|
||||||
@@ -72,12 +77,13 @@ async function load(silent = false) {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
cfgId: cfgFilter.value ?? undefined,
|
cfgId: cfgFilter.value ?? undefined,
|
||||||
})
|
})
|
||||||
|
if (seq !== loadSeq) return
|
||||||
rows.value = data.items
|
rows.value = data.items
|
||||||
pagination.itemCount = data.total
|
pagination.itemCount = data.total
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
|
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) loading.value = false
|
if (!silent && seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ const pagination = reactive({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||||
|
// 不得覆盖已切换的新页
|
||||||
|
let loadSeq = 0
|
||||||
|
|
||||||
async function load(silent = false) {
|
async function load(silent = false) {
|
||||||
|
const seq = ++loadSeq
|
||||||
if (!silent) loading.value = true
|
if (!silent) loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await listSystemLogs({
|
const data = await listSystemLogs({
|
||||||
@@ -56,12 +61,13 @@ async function load(silent = false) {
|
|||||||
pageSize: pagination.pageSize,
|
pageSize: pagination.pageSize,
|
||||||
keyword: applied.value || undefined,
|
keyword: applied.value || undefined,
|
||||||
})
|
})
|
||||||
|
if (seq !== loadSeq) return
|
||||||
rows.value = data.items
|
rows.value = data.items
|
||||||
pagination.itemCount = data.total
|
pagination.itemCount = data.total
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
|
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) loading.value = false
|
if (!silent && seq === loadSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -69,9 +70,23 @@ const result = useAsync(async () => {
|
|||||||
})
|
})
|
||||||
}, false)
|
}, false)
|
||||||
|
|
||||||
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), {
|
// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置;
|
||||||
immediate: true,
|
// 层内进目录/翻页只重查列表
|
||||||
})
|
watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope())
|
||||||
|
watch([prefix, cursor], () => void result.run(), { immediate: true })
|
||||||
|
|
||||||
|
/** 作用域变化后重置路径、游标、选中项与弹窗再重查:旧列表配新作用域时,
|
||||||
|
* 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */
|
||||||
|
function resetScope() {
|
||||||
|
prefix.value = ''
|
||||||
|
filterInput.value = ''
|
||||||
|
cursorStack.value = []
|
||||||
|
cursor.value = ''
|
||||||
|
checkedKeys.value = []
|
||||||
|
showPar.value = false
|
||||||
|
showViewer.value = false
|
||||||
|
void result.run()
|
||||||
|
}
|
||||||
|
|
||||||
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
|
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
|
||||||
const POLL_MS = 30_000
|
const POLL_MS = 30_000
|
||||||
@@ -220,14 +235,25 @@ async function cleanupPar(parId: string) {
|
|||||||
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
|
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
|
||||||
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
|
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
|
||||||
|
function willBrowserHandle(size: number): boolean {
|
||||||
|
return !size || size > DOWN_STREAM_LIMIT
|
||||||
|
}
|
||||||
|
|
||||||
function download(name: string, size?: number) {
|
function download(name: string, size?: number) {
|
||||||
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
|
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
|
||||||
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
|
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
|
||||||
return doDownload(task, name, known)
|
// 预开窗口必须在点击手势内同步进行:PAR 签发后再 window.open 已脱离手势,
|
||||||
|
// 会被弹窗策略拦截(重试按钮点击同样是手势,拦截后可恢复)
|
||||||
|
const preWin = willBrowserHandle(known) ? window.open('', '_blank') : null
|
||||||
|
return doDownload(task, name, known, preWin)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doDownload(task: TransferTask, name: string, size: number) {
|
async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) {
|
||||||
if (!props.cfgId) return
|
if (!props.cfgId) {
|
||||||
|
closeIfBlank(preWin)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const par = await createPar(props.cfgId, props.bucket, {
|
const par = await createPar(props.cfgId, props.bucket, {
|
||||||
region: props.region,
|
region: props.region,
|
||||||
@@ -235,34 +261,50 @@ async function doDownload(task: TransferTask, name: string, size: number) {
|
|||||||
accessType: 'ObjectRead',
|
accessType: 'ObjectRead',
|
||||||
expiresHours: 1,
|
expiresHours: 1,
|
||||||
})
|
})
|
||||||
const cleanable = await transferDown(task, par.fullUrl!, size)
|
const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
|
||||||
task.status = 'done'
|
task.status = 'done'
|
||||||
task.percent = 100
|
task.percent = 100
|
||||||
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
|
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
|
||||||
if (cleanable) void cleanupPar(par.id)
|
if (cleanable) void cleanupPar(par.id)
|
||||||
else parPanel.value?.refresh()
|
else parPanel.value?.refresh()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页
|
||||||
|
closeIfBlank(preWin)
|
||||||
if ((err as DOMException)?.name === 'AbortError') return
|
if ((err as DOMException)?.name === 'AbortError') return
|
||||||
task.status = 'error'
|
task.status = 'error'
|
||||||
task.error = err instanceof Error ? err.message : '下载失败'
|
task.error = err instanceof Error ? err.message : '下载失败'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
|
||||||
|
function closeIfBlank(preWin: Window | null) {
|
||||||
|
if (preWin && !preWin.closed) preWin.close()
|
||||||
|
}
|
||||||
|
|
||||||
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
|
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
|
||||||
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
|
async function transferDown(
|
||||||
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
|
task: TransferTask,
|
||||||
|
url: string,
|
||||||
|
size: number,
|
||||||
|
preWin: Window | null,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
|
||||||
try {
|
try {
|
||||||
const blob = await fetchWithProgress(task, url, size)
|
const blob = await fetchWithProgress(task, url, size)
|
||||||
saveBlob(blob, task.name)
|
saveBlob(blob, task.name)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as DOMException)?.name === 'AbortError') throw err
|
if ((err as DOMException)?.name === 'AbortError') throw err
|
||||||
return browserOpen(task, url)
|
// 流式失败回退浏览器接管:此处已脱离手势无预开窗口,被拦会如实报错
|
||||||
|
return browserOpen(task, url, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserOpen(task: TransferTask, url: string): boolean {
|
/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
|
||||||
window.open(url, '_blank')
|
function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean {
|
||||||
|
const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank')
|
||||||
|
if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试')
|
||||||
|
if (win === preWin) win.location.replace(url)
|
||||||
task.note = '已交由浏览器下载'
|
task.note = '已交由浏览器下载'
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -360,12 +402,19 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
|
|||||||
})
|
})
|
||||||
const parUrl = ref('')
|
const parUrl = ref('')
|
||||||
const parIsBucket = computed(() => !parForm.objectName)
|
const parIsBucket = computed(() => !parForm.objectName)
|
||||||
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
|
const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24
|
||||||
|
const parExpiresValid = computed(() => {
|
||||||
|
const hours = parForm.expiresHours
|
||||||
|
return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS
|
||||||
|
})
|
||||||
|
/** 有效期快捷项;也可直接输入 1 至 876000 小时 */
|
||||||
const QUICK_HOURS = [
|
const QUICK_HOURS = [
|
||||||
{ label: '1 小时', value: 1 },
|
{ label: '1 小时', value: 1 },
|
||||||
{ label: '1 天', value: 24 },
|
{ label: '1 天', value: 24 },
|
||||||
{ label: '7 天', value: 168 },
|
{ label: '7 天', value: 168 },
|
||||||
{ label: '30 天', value: 720 },
|
{ label: '30 天', value: 720 },
|
||||||
|
{ label: '1 年', value: 8760 },
|
||||||
|
{ label: '10 年', value: 87600 },
|
||||||
]
|
]
|
||||||
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
|
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
|
||||||
|
|
||||||
@@ -380,14 +429,15 @@ function openParModal(objectName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function doCreatePar() {
|
async function doCreatePar() {
|
||||||
if (!props.cfgId || !parForm.expiresHours) return
|
const expiresHours = parForm.expiresHours
|
||||||
|
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
|
||||||
parBusy.value = true
|
parBusy.value = true
|
||||||
try {
|
try {
|
||||||
const par = await createPar(props.cfgId, props.bucket, {
|
const par = await createPar(props.cfgId, props.bucket, {
|
||||||
region: props.region,
|
region: props.region,
|
||||||
objectName: parForm.objectName,
|
objectName: parForm.objectName,
|
||||||
accessType: parForm.accessType,
|
accessType: parForm.accessType,
|
||||||
expiresHours: parForm.expiresHours,
|
expiresHours,
|
||||||
})
|
})
|
||||||
parUrl.value = par.fullUrl ?? ''
|
parUrl.value = par.fullUrl ?? ''
|
||||||
parPanel.value?.refresh()
|
parPanel.value?.refresh()
|
||||||
@@ -593,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>
|
||||||
@@ -685,7 +735,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
|||||||
class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
|
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="未开版本控制时不可恢复。"
|
||||||
@@ -714,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>
|
||||||
@@ -848,7 +900,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
|||||||
:title="parIsBucket ? '分享桶' : '分享对象'"
|
:title="parIsBucket ? '分享桶' : '分享对象'"
|
||||||
:width="460"
|
:width="460"
|
||||||
:submitting="parBusy"
|
:submitting="parBusy"
|
||||||
:submit-disabled="!!parUrl || !parForm.expiresHours"
|
:submit-disabled="!!parUrl || !parExpiresValid"
|
||||||
submit-text="签发"
|
submit-text="签发"
|
||||||
@update:show="showPar = $event"
|
@update:show="showPar = $event"
|
||||||
@submit="doCreatePar"
|
@submit="doCreatePar"
|
||||||
@@ -862,27 +914,25 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
|||||||
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
|
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
|
||||||
</NRadioGroup>
|
</NRadioGroup>
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="有效期" hint="OCI 上限 30 天(720 小时)">
|
<FormField label="有效期" hint="最长 100 年(876000 小时)">
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<AppInputNumber
|
<AppInputNumber
|
||||||
v-model:value="parForm.expiresHours"
|
v-model:value="parForm.expiresHours"
|
||||||
:min="1"
|
:min="1"
|
||||||
:max="720"
|
:max="PAR_MAX_EXPIRES_HOURS"
|
||||||
:precision="0"
|
:precision="0"
|
||||||
size="small"
|
size="small"
|
||||||
class="!w-24"
|
class="!w-24"
|
||||||
/>
|
/>
|
||||||
<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
|
||||||
@@ -890,7 +940,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
|||||||
class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
|
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">
|
||||||
链接仅本次展示,关闭后无法再次获取,请立即保存
|
链接仅本次展示,关闭后无法再次获取,请立即保存
|
||||||
|
|||||||
@@ -115,8 +115,10 @@ watch(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
reset()
|
reset()
|
||||||
|
const seq = ++contentSeq
|
||||||
void detail.run().then(() => {
|
void detail.run().then(() => {
|
||||||
if (!blockedReason.value) void loadContent()
|
// detail 是共享 useAsync:换对象后旧 then 链仍会执行,按代次拦下
|
||||||
|
if (seq === contentSeq && !blockedReason.value) void loadContent()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -145,19 +147,25 @@ function revokeImage() {
|
|||||||
imageUrl.value = ''
|
imageUrl.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容加载代次:换对象/关闭即自增,旧对象的迟到响应按代次丢弃——
|
||||||
|
// 否则 A 的正文与 ETag 会顶着 B 的标题展示,编辑保存还会拿错基线
|
||||||
|
let contentSeq = 0
|
||||||
|
|
||||||
async function loadContent() {
|
async function loadContent() {
|
||||||
if (!props.cfgId || loading.value) return
|
if (!props.cfgId || loading.value) return
|
||||||
|
const seq = ++contentSeq
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
try {
|
try {
|
||||||
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
|
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
|
||||||
|
if (seq !== contentSeq) return
|
||||||
contentEtag.value = c.etag
|
contentEtag.value = c.etag
|
||||||
contentType.value = c.contentType
|
contentType.value = c.contentType
|
||||||
await applyContent(c.blob)
|
await applyContent(c.blob)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e instanceof Error ? e.message : '内容加载失败'
|
if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (seq === contentSeq) loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const pars = useAsync<ParPage>(async () => {
|
|||||||
const items = computed(() => pars.data.value?.items ?? [])
|
const items = computed(() => pars.data.value?.items ?? [])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => props.cfgId, () => props.bucket],
|
// region 必须在依赖里:切区域后旧列表配新区域,撤销会删错区域的同名 PAR
|
||||||
|
[() => props.cfgId, () => props.region, () => props.bucket],
|
||||||
() => {
|
() => {
|
||||||
cursorStack.value = []
|
cursorStack.value = []
|
||||||
cursor.value = ''
|
cursor.value = ''
|
||||||
@@ -199,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,32 +385,20 @@ 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
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
|
const pFormValid = computed(
|
||||||
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest {
|
() =>
|
||||||
const cfg = oauthCfg.data.value
|
Boolean(pForm.clientId.trim()) &&
|
||||||
return {
|
(pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())) &&
|
||||||
oidcIssuer: cfg?.oidcIssuer ?? '',
|
(secretConfigured.value || Boolean(pForm.secret)),
|
||||||
oidcClientId: cfg?.oidcClientId ?? '',
|
)
|
||||||
oidcDisplayName: cfg?.oidcDisplayName ?? '',
|
|
||||||
oidcDisabled: cfg?.oidcDisabled ?? false,
|
|
||||||
githubClientId: cfg?.githubClientId ?? '',
|
|
||||||
githubDisplayName: cfg?.githubDisplayName ?? '',
|
|
||||||
githubDisabled: cfg?.githubDisabled ?? false,
|
|
||||||
...patch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
|
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
|
||||||
function providerFormPatch(): Partial<UpdateOAuthRequest> {
|
function providerFormPatch(): UpdateOAuthRequest {
|
||||||
const name = pForm.displayName.trim()
|
const name = pForm.displayName.trim()
|
||||||
const id = pForm.clientId.trim()
|
const id = pForm.clientId.trim()
|
||||||
if (pForm.provider === 'oidc')
|
if (pForm.provider === 'oidc')
|
||||||
@@ -358,7 +418,7 @@ function providerFormPatch(): Partial<UpdateOAuthRequest> {
|
|||||||
async function saveProvider() {
|
async function saveProvider() {
|
||||||
savingOauth.value = true
|
savingOauth.value = true
|
||||||
try {
|
try {
|
||||||
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
|
await updateOAuthSettings(providerFormPatch())
|
||||||
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
|
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
|
||||||
showProviderModal.value = false
|
showProviderModal.value = false
|
||||||
void oauthCfg.run({ silent: true })
|
void oauthCfg.run({ silent: true })
|
||||||
@@ -374,7 +434,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
|
|||||||
savingOauth.value = true
|
savingOauth.value = true
|
||||||
try {
|
try {
|
||||||
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
|
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
|
||||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
await updateOAuthSettings(patch)
|
||||||
message.success(`「${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
|
message.success(`「${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
|
||||||
void oauthCfg.run({ silent: true })
|
void oauthCfg.run({ silent: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -393,7 +453,7 @@ async function deleteProvider(p: ProviderType) {
|
|||||||
p === 'oidc'
|
p === 'oidc'
|
||||||
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
|
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
|
||||||
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
|
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
|
||||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
await updateOAuthSettings(patch)
|
||||||
message.success(`登录方式「${name}」已删除`)
|
message.success(`登录方式「${name}」已删除`)
|
||||||
void oauthCfg.run({ silent: true })
|
void oauthCfg.run({ silent: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -405,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"
|
||||||
@@ -427,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"
|
||||||
@@ -447,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>
|
||||||
@@ -484,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="解绑后该身份无法再登录面板。"
|
||||||
@@ -513,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>
|
||||||
@@ -542,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>
|
||||||
@@ -554,7 +679,7 @@ async function deleteProvider(p: ProviderType) {
|
|||||||
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
|
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</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>
|
||||||
@@ -570,13 +695,26 @@ async function deleteProvider(p: ProviderType) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="border-b border-line-soft px-3 py-2.5">
|
<td class="border-b border-line-soft px-3 py-2.5">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
|
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
|
||||||
>
|
>
|
||||||
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
|
<span
|
||||||
{{ 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"
|
||||||
@@ -605,7 +743,7 @@ async function deleteProvider(p: ProviderType) {
|
|||||||
</div>
|
</div>
|
||||||
<FootNote>
|
<FootNote>
|
||||||
在 IdP / GitHub 应用中填写的授权回调地址:
|
在 IdP / GitHub 应用中填写的授权回调地址:
|
||||||
{{ callbackBase}}/api/v1/auth/oauth/<github|oidc>/callback
|
{{ callbackBase }}/api/v1/auth/oauth/<github|oidc>/callback;已绑定的身份在上方「免密登录」中解绑
|
||||||
</FootNote>
|
</FootNote>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,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]}」`">
|
||||||
@@ -668,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" />
|
||||||
@@ -719,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 }}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ watch(
|
|||||||
if (t.type === 'snatch') {
|
if (t.type === 'snatch') {
|
||||||
const inst = (p.instance ?? {}) as Record<string, unknown>
|
const inst = (p.instance ?? {}) as Record<string, unknown>
|
||||||
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
|
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
|
||||||
form.count = typeof p.count === 'number' ? p.count : 1
|
// 表单编辑的是目标台数:优先 totalCount,旧任务缺省回退剩余台数
|
||||||
|
const target = typeof p.totalCount === 'number' ? p.totalCount : p.count
|
||||||
|
form.count = typeof target === 'number' ? target : 1
|
||||||
form.displayName = String(inst.displayName ?? '')
|
form.displayName = String(inst.displayName ?? '')
|
||||||
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
|
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
|
||||||
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
|
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -182,7 +182,11 @@ function rowProps(row: AuditEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情
|
||||||
|
let rawSeq = 0
|
||||||
|
|
||||||
async function loadRaw(row: AuditEvent) {
|
async function loadRaw(row: AuditEvent) {
|
||||||
|
const seq = ++rawSeq
|
||||||
detailRaw.value = null
|
detailRaw.value = null
|
||||||
rawGone.value = false
|
rawGone.value = false
|
||||||
if (!row.eventId || !row.eventTime) {
|
if (!row.eventId || !row.eventTime) {
|
||||||
@@ -195,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
|
|||||||
eventId: row.eventId,
|
eventId: row.eventId,
|
||||||
eventTime: row.eventTime,
|
eventTime: row.eventTime,
|
||||||
})
|
})
|
||||||
detailRaw.value = raw
|
if (seq === rawSeq) detailRaw.value = raw
|
||||||
} catch {
|
} catch {
|
||||||
rawGone.value = true
|
if (seq === rawSeq) rawGone.value = true
|
||||||
} finally {
|
} finally {
|
||||||
rawLoading.value = false
|
if (seq === rawSeq) rawLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
import { NButton, NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
|
import {
|
||||||
|
createIdp,
|
||||||
|
deleteIdpIcon,
|
||||||
|
getIdentitySetting,
|
||||||
|
uploadIdpIcon,
|
||||||
|
type CreateIdpBody,
|
||||||
|
} from '@/api/tenant'
|
||||||
import FilePicker from '@/components/FilePicker.vue'
|
import FilePicker from '@/components/FilePicker.vue'
|
||||||
import FormField from '@/components/FormField.vue'
|
import FormField from '@/components/FormField.vue'
|
||||||
import FormModal from '@/components/FormModal.vue'
|
import FormModal from '@/components/FormModal.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import type { IdpIconUpload } from '@/types/api'
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
|
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
|
||||||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||||||
@@ -15,6 +22,7 @@ const message = useToast()
|
|||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
|
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
|
||||||
const primaryEmailRequired = ref(false)
|
const primaryEmailRequired = ref(false)
|
||||||
|
const identitySettingReady = ref(false)
|
||||||
|
|
||||||
const nameIdFormatOptions = [
|
const nameIdFormatOptions = [
|
||||||
{ label: '无', value: 'saml-none' },
|
{ label: '无', value: 'saml-none' },
|
||||||
@@ -51,22 +59,66 @@ const blank = {
|
|||||||
}
|
}
|
||||||
const form = reactive({ ...blank })
|
const form = reactive({ ...blank })
|
||||||
|
|
||||||
|
// ---- 图标上传:传到身份域公共图片存储(/storage/v1/Images),回填公网 URL ----
|
||||||
|
const iconFileEl = ref<HTMLInputElement | null>(null)
|
||||||
|
const iconUploading = ref(false)
|
||||||
|
// 最近上传且尚未被创建采用的图标;替换、放弃表单或提交未引用时尽力删除。
|
||||||
|
// 删除失败只会在自己租户里留下一张无入口的小图,静默接受,不打扰用户。
|
||||||
|
let pendingIcon: (IdpIconUpload & { cfgId: number; domainId?: string }) | null = null
|
||||||
|
let identitySettingSeq = 0
|
||||||
|
// 图片加载失败时回退占位,避免预览框里出现浏览器裂图
|
||||||
|
const iconError = ref(false)
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => form.iconUrl,
|
||||||
(v) => {
|
() => (iconError.value = false),
|
||||||
if (!v) return
|
)
|
||||||
|
// 上传请求序号:重开弹窗或重新选文件即自增,旧请求的响应按序号丢弃
|
||||||
|
let iconUploadSeq = 0
|
||||||
|
|
||||||
|
function invalidateIconUpload() {
|
||||||
|
iconUploadSeq++
|
||||||
|
iconUploading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPendingIcon() {
|
||||||
|
const icon = pendingIcon
|
||||||
|
pendingIcon = null
|
||||||
|
if (icon) void deleteIdpIcon(icon.cfgId, icon.fileName, icon.domainId).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function abandonForm() {
|
||||||
|
identitySettingSeq++
|
||||||
|
identitySettingReady.value = false
|
||||||
|
invalidateIconUpload()
|
||||||
|
cleanupPendingIcon()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
abandonForm()
|
||||||
|
primaryEmailRequired.value = false
|
||||||
Object.assign(form, blank)
|
Object.assign(form, blank)
|
||||||
void loadIdentitySetting()
|
void loadIdentitySetting()
|
||||||
},
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.cfgId, props.domainId] as const,
|
||||||
|
([show]) => (show ? resetForm() : abandonForm()),
|
||||||
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// show/cfgId/domainId 任一变化都会经 abandonForm 递增序号,故过期只需比对序号
|
||||||
async function loadIdentitySetting() {
|
async function loadIdentitySetting() {
|
||||||
|
const seq = ++identitySettingSeq
|
||||||
|
identitySettingReady.value = false
|
||||||
try {
|
try {
|
||||||
const s = await getIdentitySetting(props.cfgId)
|
const s = await getIdentitySetting(props.cfgId, props.domainId)
|
||||||
|
if (seq !== identitySettingSeq) return
|
||||||
primaryEmailRequired.value = s.primaryEmailRequired
|
primaryEmailRequired.value = s.primaryEmailRequired
|
||||||
if (s.primaryEmailRequired) form.jitMapEmail = true
|
if (s.primaryEmailRequired) form.jitMapEmail = true
|
||||||
} catch {
|
} catch {
|
||||||
primaryEmailRequired.value = false
|
if (seq === identitySettingSeq) primaryEmailRequired.value = false
|
||||||
|
} finally {
|
||||||
|
if (seq === identitySettingSeq) identitySettingReady.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +128,60 @@ const iconUrlValid = computed(() => {
|
|||||||
return !v || /^https?:\/\//.test(v)
|
return !v || /^https?:\/\//.test(v)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function onIconPick(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ''
|
||||||
|
if (!file || submitting.value) return
|
||||||
|
if (file.size > 1 << 20) {
|
||||||
|
message.error('图标不能超过 1MB')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { cfgId, domainId } = props
|
||||||
|
const seq = ++iconUploadSeq
|
||||||
|
iconUploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadIdpIcon(cfgId, file, domainId)
|
||||||
|
if (seq !== iconUploadSeq) {
|
||||||
|
void deleteIdpIcon(cfgId, result.fileName, domainId).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cleanupPendingIcon()
|
||||||
|
pendingIcon = { ...result, cfgId, domainId }
|
||||||
|
form.iconUrl = result.url
|
||||||
|
message.success('图标已上传到身份域公共存储')
|
||||||
|
} catch (err) {
|
||||||
|
if (seq === iconUploadSeq) message.error(err instanceof Error ? err.message : '上传失败')
|
||||||
|
} finally {
|
||||||
|
if (seq === iconUploadSeq) iconUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onIconUrlInput(value: string) {
|
||||||
|
if (submitting.value || value === form.iconUrl) return
|
||||||
|
invalidateIconUpload()
|
||||||
|
form.iconUrl = value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
abandonForm()
|
||||||
|
emit('update:show', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onModalShowChange(show: boolean) {
|
||||||
|
if (!show && submitting.value) return
|
||||||
|
if (show) emit('update:show', true)
|
||||||
|
else closeForm()
|
||||||
|
}
|
||||||
|
|
||||||
const canSubmit = computed(
|
const canSubmit = computed(
|
||||||
() =>
|
() =>
|
||||||
!!form.name.trim() &&
|
!!form.name.trim() &&
|
||||||
form.metadata.includes('EntityDescriptor') &&
|
form.metadata.includes('EntityDescriptor') &&
|
||||||
iconUrlValid.value &&
|
iconUrlValid.value &&
|
||||||
|
!iconUploading.value &&
|
||||||
|
identitySettingReady.value &&
|
||||||
|
!submitting.value &&
|
||||||
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
|
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
if (submitting.value || !canSubmit.value) return
|
||||||
|
const body = buildBody()
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
|
const created = await createIdp(props.cfgId, body, props.domainId)
|
||||||
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
// 创建请求引用了刚上传的图标即视为已采用,交给 IdP 生命周期,不再清理
|
||||||
emit('update:show', false)
|
if (pendingIcon && pendingIcon.url === body.iconUrl) pendingIcon = null
|
||||||
|
const warning = created.setupWarning
|
||||||
|
if (warning)
|
||||||
|
message.warning(`IdP「${created.name}」的创建结果需确认`, `${warning.message}\n关联 ID:${warning.requestId}`)
|
||||||
|
else message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||||||
|
closeForm()
|
||||||
emit('created')
|
emit('created')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '创建失败')
|
message.error(e instanceof Error ? e.message : '创建失败')
|
||||||
@@ -124,12 +232,17 @@ async function submit() {
|
|||||||
:submitting="submitting"
|
:submitting="submitting"
|
||||||
:submit-disabled="!canSubmit"
|
:submit-disabled="!canSubmit"
|
||||||
submit-text="创建(禁用态)"
|
submit-text="创建(禁用态)"
|
||||||
@update:show="emit('update:show', $event)"
|
@update:show="onModalShowChange"
|
||||||
@submit="submit"
|
@submit="submit"
|
||||||
>
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||||
<FormField label="名称" required>
|
<FormField label="名称" required>
|
||||||
<NInput v-model:value="form.name" placeholder="my-idp" />
|
<NInput v-model:value="form.name" placeholder="my-idp" />
|
||||||
</FormField>
|
</FormField>
|
||||||
|
<FormField label="备注">
|
||||||
|
<NInput v-model:value="form.description" placeholder="可选" />
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
<FormField
|
<FormField
|
||||||
label="IdP SAML metadata XML 文件"
|
label="IdP SAML metadata XML 文件"
|
||||||
required
|
required
|
||||||
@@ -153,26 +266,49 @@ async function submit() {
|
|||||||
@load="(text) => (form.metadata = text)"
|
@load="(text) => (form.metadata = text)"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
|
||||||
<FormField label="备注">
|
|
||||||
<NInput v-model:value="form.description" placeholder="可选" />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Logo(登录页图标)"
|
label="Logo(登录页图标)"
|
||||||
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
|
hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
|
||||||
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
|
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="flex h-[34px] w-[34px] flex-none items-center justify-center overflow-hidden rounded border border-line-soft bg-wash"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
v-if="form.iconUrl && iconUrlValid"
|
v-if="form.iconUrl && iconUrlValid && !iconError"
|
||||||
:src="form.iconUrl"
|
:src="form.iconUrl"
|
||||||
alt=""
|
alt=""
|
||||||
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
|
class="h-full w-full object-contain"
|
||||||
|
@error="iconError = true"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-[10px] text-ink-3 select-none">Logo</span>
|
||||||
|
</div>
|
||||||
|
<NInput
|
||||||
|
:value="form.iconUrl"
|
||||||
|
:disabled="submitting"
|
||||||
|
placeholder="https://…/logo.png,或点右侧上传"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
@update:value="onIconUrlInput"
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
class="flex-none"
|
||||||
|
:loading="iconUploading"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="iconFileEl?.click()"
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</NButton>
|
||||||
|
<input
|
||||||
|
ref="iconFileEl"
|
||||||
|
type="file"
|
||||||
|
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="hidden"
|
||||||
|
@change="onIconPick"
|
||||||
/>
|
/>
|
||||||
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
|
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
|
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
|
||||||
映射用户身份
|
映射用户身份
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { useAsync } from './useAsync'
|
||||||
|
|
||||||
|
/** 手动控制解析时机的 fetcher,复现请求乱序返回 */
|
||||||
|
function deferredFetcher<T>() {
|
||||||
|
const pending: Array<{ resolve: (v: T) => void; reject: (e: unknown) => void }> = []
|
||||||
|
const fetcher = () =>
|
||||||
|
new Promise<T>((resolve, reject) => {
|
||||||
|
pending.push({ resolve, reject })
|
||||||
|
})
|
||||||
|
return { fetcher, pending }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAsync 并发与乱序', () => {
|
||||||
|
it('后发请求先返回时,先发请求的迟到结果被丢弃', async () => {
|
||||||
|
const { fetcher, pending } = deferredFetcher<string>()
|
||||||
|
const st = useAsync(fetcher, false)
|
||||||
|
const p1 = st.run()
|
||||||
|
const p2 = st.run()
|
||||||
|
pending[1].resolve('新')
|
||||||
|
await p2
|
||||||
|
pending[0].resolve('旧')
|
||||||
|
await p1
|
||||||
|
expect(st.data.value).toBe('新')
|
||||||
|
expect(st.loading.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('迟到的失败不覆盖最新成功结果', async () => {
|
||||||
|
const { fetcher, pending } = deferredFetcher<string>()
|
||||||
|
const st = useAsync(fetcher, false)
|
||||||
|
const p1 = st.run()
|
||||||
|
const p2 = st.run()
|
||||||
|
pending[1].resolve('新')
|
||||||
|
await p2
|
||||||
|
pending[0].reject(new Error('旧请求失败'))
|
||||||
|
await p1
|
||||||
|
expect(st.data.value).toBe('新')
|
||||||
|
expect(st.error.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('silent 刷新不置 loading,普通刷新置 loading', async () => {
|
||||||
|
const { fetcher, pending } = deferredFetcher<number>()
|
||||||
|
const st = useAsync(fetcher, false)
|
||||||
|
const p1 = st.run({ silent: true })
|
||||||
|
expect(st.loading.value).toBe(false)
|
||||||
|
pending[0].resolve(1)
|
||||||
|
await p1
|
||||||
|
const p2 = st.run()
|
||||||
|
expect(st.loading.value).toBe(true)
|
||||||
|
pending[1].resolve(2)
|
||||||
|
await p2
|
||||||
|
expect(st.loading.value).toBe(false)
|
||||||
|
expect(st.data.value).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('失败时记录 error 并清 loading', async () => {
|
||||||
|
const { fetcher, pending } = deferredFetcher<string>()
|
||||||
|
const st = useAsync(fetcher, false)
|
||||||
|
const p1 = st.run()
|
||||||
|
pending[0].reject(new Error('boom'))
|
||||||
|
await p1
|
||||||
|
expect(st.error.value).toBe('boom')
|
||||||
|
expect(st.loading.value).toBe(false)
|
||||||
|
expect(st.data.value).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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']
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器注入钱包(EIP-1193)接入:EIP-6963 多钱包发现 + personal_sign 封装。
|
||||||
|
* 不引钱包 SDK;WalletConnect 等远程签名方案不在支持范围。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** EIP-1193 provider 最小接口 */
|
||||||
|
export interface Eip1193Provider {
|
||||||
|
request(args: { method: string; params?: unknown[] }): Promise<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EIP-6963 announce 事件携带的钱包信息 */
|
||||||
|
export interface WalletDetail {
|
||||||
|
info: { uuid: string; name: string; icon: string; rdns: string }
|
||||||
|
provider: Eip1193Provider
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Eip6963AnnounceEvent extends Event {
|
||||||
|
detail: WalletDetail
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧式单一注入的 window.ethereum */
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ethereum?: Eip1193Provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现已安装的注入钱包:广播 EIP-6963 请求并收集 announce;
|
||||||
|
* 无响应时回退 window.ethereum(旧式单钱包注入)。
|
||||||
|
*/
|
||||||
|
export function discoverWallets(collectMs = 300): Promise<WalletDetail[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const found: WalletDetail[] = []
|
||||||
|
const onAnnounce = (e: Event) => {
|
||||||
|
const detail = (e as Eip6963AnnounceEvent).detail
|
||||||
|
if (detail?.provider && !found.some((w) => w.info.uuid === detail.info.uuid)) found.push(detail)
|
||||||
|
}
|
||||||
|
window.addEventListener('eip6963:announceProvider', onAnnounce)
|
||||||
|
window.dispatchEvent(new Event('eip6963:requestProvider'))
|
||||||
|
setTimeout(() => {
|
||||||
|
window.removeEventListener('eip6963:announceProvider', onAnnounce)
|
||||||
|
if (!found.length && window.ethereum) {
|
||||||
|
found.push({
|
||||||
|
info: { uuid: 'legacy', name: '浏览器钱包', icon: '', rdns: 'legacy.injected' },
|
||||||
|
provider: window.ethereum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resolve(found)
|
||||||
|
}, collectMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接钱包并返回首个授权账户地址;用户拒绝时抛出钱包侧错误 */
|
||||||
|
export async function requestAccount(provider: Eip1193Provider): Promise<string> {
|
||||||
|
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[]
|
||||||
|
const addr = accounts?.[0]
|
||||||
|
if (!addr) throw new Error('钱包未返回账户地址')
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对消息做 personal_sign(EIP-191);返回 0x 前缀的 65 字节签名 hex */
|
||||||
|
export async function personalSign(
|
||||||
|
provider: Eip1193Provider,
|
||||||
|
message: string,
|
||||||
|
address: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const sig = (await provider.request({
|
||||||
|
method: 'personal_sign',
|
||||||
|
params: [message, address],
|
||||||
|
})) as string
|
||||||
|
if (!sig || typeof sig !== 'string') throw new Error('钱包未返回签名')
|
||||||
|
return sig
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 钱包侧用户拒绝(EIP-1193 code 4001);拒绝不作为错误提示 */
|
||||||
|
export function isUserRejected(e: unknown): boolean {
|
||||||
|
return typeof e === 'object' && e !== null && (e as { code?: number }).code === 4001
|
||||||
|
}
|
||||||
+2
-2
@@ -100,8 +100,8 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (!to.meta.public && !auth.isAuthed) return { name: 'login', query: { redirect: to.fullPath } }
|
if (!to.meta.public && !auth.isAuthed()) return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
if (to.name === 'login' && auth.isAuthed) return { name: 'overview' }
|
if (to.name === 'login' && auth.isAuthed()) return { name: 'overview' }
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { useAuthStore } from './auth'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isAuthed 实时过期判定', () => {
|
||||||
|
const cases = [
|
||||||
|
{ name: '无 token 未登录', token: '', expiresAt: '', want: false },
|
||||||
|
{ name: '有 token 无过期时间视为已登录', token: 't', expiresAt: '', want: true },
|
||||||
|
{
|
||||||
|
name: '未过期',
|
||||||
|
token: 't',
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '已过期(computed 缓存会误判的场景)',
|
||||||
|
token: 't',
|
||||||
|
expiresAt: new Date(Date.now() - 1_000).toISOString(),
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for (const c of cases) {
|
||||||
|
it(c.name, () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
if (c.token) auth.setSession(c.token, c.expiresAt)
|
||||||
|
expect(auth.isAuthed()).toBe(c.want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('logout 清空会话与本地存储', () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
auth.setSession('t', new Date(Date.now() + 60_000).toISOString())
|
||||||
|
auth.logout()
|
||||||
|
expect(auth.token).toBe('')
|
||||||
|
expect(auth.isAuthed()).toBe(false)
|
||||||
|
expect(localStorage.getItem('oci-portal.token')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setSession 落地本地存储,新 store 实例可恢复', () => {
|
||||||
|
useAuthStore().setSession('t2', '')
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
expect(useAuthStore().token).toBe('t2')
|
||||||
|
})
|
||||||
+5
-3
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
const TOKEN_KEY = 'oci-portal.token'
|
const TOKEN_KEY = 'oci-portal.token'
|
||||||
const EXPIRES_KEY = 'oci-portal.expiresAt'
|
const EXPIRES_KEY = 'oci-portal.expiresAt'
|
||||||
@@ -8,11 +8,13 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
|
const token = ref(localStorage.getItem(TOKEN_KEY) ?? '')
|
||||||
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
|
const expiresAt = ref(localStorage.getItem(EXPIRES_KEY) ?? '')
|
||||||
|
|
||||||
const isAuthed = computed(() => {
|
// 普通函数而非 computed:computed 只随 token/expiresAt 失效,时间流逝不会
|
||||||
|
// 触发重算,令牌自然过期后路由守卫会长期误判仍已登录
|
||||||
|
function isAuthed(): boolean {
|
||||||
if (!token.value) return false
|
if (!token.value) return false
|
||||||
if (!expiresAt.value) return true
|
if (!expiresAt.value) return true
|
||||||
return new Date(expiresAt.value).getTime() > Date.now()
|
return new Date(expiresAt.value).getTime() > Date.now()
|
||||||
})
|
}
|
||||||
|
|
||||||
function setSession(newToken: string, newExpiresAt: string) {
|
function setSession(newToken: string, newExpiresAt: string) {
|
||||||
token.value = newToken
|
token.value = newToken
|
||||||
|
|||||||
+4
-1
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-8
@@ -66,17 +66,17 @@ export interface OAuthSettings {
|
|||||||
githubDisabled: boolean
|
githubDisabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 保存 provider 配置;secret 缺省沿用、空串清除 */
|
/** provider 配置字段补丁:缺省字段沿用现值;secret 空串清除、缺省沿用 */
|
||||||
export interface UpdateOAuthRequest {
|
export interface UpdateOAuthRequest {
|
||||||
oidcIssuer: string
|
oidcIssuer?: string
|
||||||
oidcClientId: string
|
oidcClientId?: string
|
||||||
oidcClientSecret?: string
|
oidcClientSecret?: string
|
||||||
oidcDisplayName: string
|
oidcDisplayName?: string
|
||||||
oidcDisabled: boolean
|
oidcDisabled?: boolean
|
||||||
githubClientId: string
|
githubClientId?: string
|
||||||
githubClientSecret?: string
|
githubClientSecret?: string
|
||||||
githubDisplayName: string
|
githubDisplayName?: string
|
||||||
githubDisabled: boolean
|
githubDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */
|
/** 登录页公开的 provider 信息;displayName 空配置时后端已回默认名 */
|
||||||
@@ -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
|
||||||
@@ -699,6 +741,13 @@ export interface IdentitySetting {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Federation ----
|
// ---- Federation ----
|
||||||
|
export interface IdpSetupWarning {
|
||||||
|
code: 'JIT_SETUP_INCOMPLETE'
|
||||||
|
message: string
|
||||||
|
resourceCreated: true
|
||||||
|
requestId: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface IdentityProvider {
|
export interface IdentityProvider {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -707,6 +756,14 @@ export interface IdentityProvider {
|
|||||||
partnerProviderId: string
|
partnerProviderId: string
|
||||||
jitEnabled: boolean
|
jitEnabled: boolean
|
||||||
timeCreated: string
|
timeCreated: string
|
||||||
|
/** 资源曾创建且回滚状态不确定;创建方必须按资源可能存在处理 */
|
||||||
|
setupWarning?: IdpSetupWarning
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IdP 图标上传结果;fileName 为身份域存储内标识,留作后续清理 */
|
||||||
|
export interface IdpIconUpload {
|
||||||
|
url: string
|
||||||
|
fileName: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignOnRule {
|
export interface SignOnRule {
|
||||||
@@ -756,6 +813,14 @@ export interface OverviewCost {
|
|||||||
currency: string
|
currency: string
|
||||||
total: number
|
total: number
|
||||||
days: OverviewCostDay[]
|
days: OverviewCostDay[]
|
||||||
|
/** 按币种拆分的序列(合计降序);顶层 currency/total/days 恒为首个主币种,跨币种不相加 */
|
||||||
|
series: OverviewCostSeries[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverviewCostSeries {
|
||||||
|
currency: string
|
||||||
|
total: number
|
||||||
|
days: OverviewCostDay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverviewTasks {
|
export interface OverviewTasks {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ const currencySymbol = computed(() => {
|
|||||||
const currency = ov.value?.cost.currency
|
const currency = ov.value?.cost.currency
|
||||||
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
|
return currency === 'EUR' ? '€' : currency === 'GBP' ? '£' : '$'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 主币种之外的其余币种合计;跨币种金额不可相加,只并列展示 */
|
||||||
|
const otherCurrencyTotals = computed(() =>
|
||||||
|
(ov.value?.cost.series ?? [])
|
||||||
|
.slice(1)
|
||||||
|
.map((s) => `${s.currency} ${s.total.toFixed(2)}`)
|
||||||
|
.join('、'),
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,6 +134,9 @@ const currencySymbol = computed(() => {
|
|||||||
<div class="text-[12.5px] text-ink-3">
|
<div class="text-[12.5px] text-ink-3">
|
||||||
{{ costDaily.labels[0] }} 至 {{ costDaily.labels.at(-1) }} 合计
|
{{ costDaily.labels[0] }} 至 {{ costDaily.labels.at(-1) }} 合计
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="otherCurrencyTotals" class="text-[12.5px] text-ink-3">
|
||||||
|
另有 {{ otherCurrencyTotals }}(跨币种不相加)
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="px-3 pb-2">
|
<div class="px-3 pb-2">
|
||||||
<CostChart :labels="costDaily.labels" :values="costDaily.values" />
|
<CostChart :labels="costDaily.labels" :values="costDaily.values" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
+36
-38
@@ -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'
|
||||||
@@ -42,12 +44,15 @@ const auth = useAuthStore()
|
|||||||
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
|
// 设置页以 tab 组织:通知(管理 + 方式)/ 任务(抢机熔断阈值)/ 安全(WAF/网络/账号)
|
||||||
const tab = ref('notify')
|
const tab = ref('notify')
|
||||||
|
|
||||||
// OAuth 绑定回跳:成功/失败提示后清理 query,并停在安全 tab;
|
// OAuth 绑定回跳:绑定使令牌版本递增,fragment 携带的新 token 必须在本组件
|
||||||
// 绑定使令牌版本递增,fragment 携带的新 token 须先落地,否则后续请求 401
|
// 任何请求发起前(setup 顶部)落地——setup 阶段的 useAsync 会立即用旧 token
|
||||||
|
// 发请求,迟到的 401 会误清刚写入的新会话
|
||||||
|
consumeBindToken()
|
||||||
|
|
||||||
|
// 成功/失败提示后清理 query,并停在安全 tab
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const { oauth, oauthError } = route.query
|
const { oauth, oauthError } = route.query
|
||||||
if (oauth !== 'bound' && !oauthError) return
|
if (oauth !== 'bound' && !oauthError) return
|
||||||
consumeBindToken()
|
|
||||||
tab.value = 'security'
|
tab.value = 'security'
|
||||||
if (oauth === 'bound') message.success('外部身份绑定成功')
|
if (oauth === 'bound') message.success('外部身份绑定成功')
|
||||||
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
|
if (typeof oauthError === 'string' && oauthError) message.error(oauthError)
|
||||||
@@ -244,7 +249,7 @@ function pickHeader(v: string) {
|
|||||||
customHeaderMode.value = false
|
customHeaderMode.value = false
|
||||||
if (securityForm.value.realIpHeader === v) return
|
if (securityForm.value.realIpHeader === v) return
|
||||||
securityForm.value.realIpHeader = v
|
securityForm.value.realIpHeader = v
|
||||||
void saveSecurity()
|
void saveSecurity({ realIpHeader: v })
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickCustomHeader() {
|
function pickCustomHeader() {
|
||||||
@@ -265,18 +270,27 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 控件变更即全量 PUT(与 AI 设置一致);失败重拉服务端值回滚 UI */
|
/** 控件变更即发字段补丁,服务端只落库出现字段(并发编辑互不回滚);
|
||||||
async function saveSecurity() {
|
* 失败重拉服务端值回滚 UI。在途时把补丁并入尾随一轮,完成后合并补发。 */
|
||||||
|
let pendingSecurityPatch: Partial<SecuritySetting> | null = null
|
||||||
|
async function saveSecurity(patch: Partial<SecuritySetting>) {
|
||||||
|
if (savingSecurity.value) {
|
||||||
|
pendingSecurityPatch = { ...pendingSecurityPatch, ...patch }
|
||||||
|
return
|
||||||
|
}
|
||||||
savingSecurity.value = true
|
savingSecurity.value = true
|
||||||
try {
|
try {
|
||||||
await updateSecuritySetting({ ...securityForm.value })
|
await updateSecuritySetting(patch)
|
||||||
message.success('已保存,立即生效')
|
message.success('已保存,立即生效')
|
||||||
void security.run({ silent: true })
|
if (!pendingSecurityPatch) void security.run({ silent: true })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '保存失败')
|
message.error(e instanceof Error ? e.message : '保存失败')
|
||||||
void security.run({ silent: true })
|
if (!pendingSecurityPatch) void security.run({ silent: true })
|
||||||
} finally {
|
} finally {
|
||||||
savingSecurity.value = false
|
savingSecurity.value = false
|
||||||
|
const next = pendingSecurityPatch
|
||||||
|
pendingSecurityPatch = null
|
||||||
|
if (next) void saveSecurity(next)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +301,7 @@ function commitSecurityNum(
|
|||||||
) {
|
) {
|
||||||
if (v == null || v === securityForm.value[key]) return
|
if (v == null || v === securityForm.value[key]) return
|
||||||
securityForm.value[key] = v
|
securityForm.value[key] = v
|
||||||
void saveSecurity()
|
void saveSecurity({ [key]: v })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveTaskSetting() {
|
async function saveTaskSetting() {
|
||||||
@@ -581,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>
|
||||||
@@ -644,39 +658,24 @@ 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"
|
||||||
v-model:value="securityForm.realIpHeader"
|
v-model:value="securityForm.realIpHeader"
|
||||||
class="mono mt-2 !w-70"
|
class="mono mt-2 !w-70"
|
||||||
placeholder="自定义头名,如 X-Client-IP"
|
placeholder="自定义头名,如 X-Client-IP"
|
||||||
@change="() => void saveSecurity()"
|
@change="() => void saveSecurity({ realIpHeader: securityForm.realIpHeader })"
|
||||||
/>
|
/>
|
||||||
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
|
<div v-if="customHeaderMode" class="mt-1.5 text-xs text-ink-3">
|
||||||
头名仅限字母数字与连字符
|
头名仅限字母数字与连字符
|
||||||
@@ -689,7 +688,7 @@ async function sendTest() {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="securityForm.appUrl"
|
v-model:value="securityForm.appUrl"
|
||||||
placeholder="https://demo.example.com"
|
placeholder="https://demo.example.com"
|
||||||
@change="() => void saveSecurity()"
|
@change="() => void saveSecurity({ appUrl: securityForm.appUrl })"
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
@@ -699,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 工具默认 / 模型治理) -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -20,9 +20,19 @@ const tasks = useAsync(listTasks)
|
|||||||
const showCreate = ref(false)
|
const showCreate = ref(false)
|
||||||
const editingTask = ref<Task | null>(null)
|
const editingTask = ref<Task | null>(null)
|
||||||
|
|
||||||
// 每 5 秒静默刷新(不闪 loading)
|
// 每 5 秒静默刷新(不闪 loading);上一轮完成后再排下一轮,
|
||||||
const refreshTimer = setInterval(() => void tasks.run({ silent: true }), 5000)
|
// 接口变慢时不堆叠请求,也避免响应总被下一轮标记过期而饿死
|
||||||
onUnmounted(() => clearInterval(refreshTimer))
|
let refreshTimer = 0
|
||||||
|
let refreshStopped = false
|
||||||
|
async function refreshLoop() {
|
||||||
|
await tasks.run({ silent: true })
|
||||||
|
if (!refreshStopped) refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
|
||||||
|
}
|
||||||
|
refreshTimer = window.setTimeout(() => void refreshLoop(), 5000)
|
||||||
|
onUnmounted(() => {
|
||||||
|
refreshStopped = true
|
||||||
|
clearTimeout(refreshTimer)
|
||||||
|
})
|
||||||
|
|
||||||
const cfgAliasById = computed(
|
const cfgAliasById = computed(
|
||||||
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),
|
() => new Map((scope.configs.data ?? []).map((c) => [c.id, c.alias])),
|
||||||
@@ -43,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,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>
|
||||||
@@ -82,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
|
||||||
@@ -97,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)"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+7
-2
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"extends": "@tsconfig/node22/tsconfig.json",
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
"include": ["vite.config.ts"],
|
"include": [
|
||||||
|
"vite.config.ts",
|
||||||
|
"vitest.config.ts"
|
||||||
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"types": ["node"]
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
// 测试独立配置,不复用 vite.config:其 PWA/手动分包插件与单测无关且拖慢启动
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user