Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec8a50c4c0 | ||
|
|
d675b950ee | ||
|
|
d0fbecbd43 | ||
|
|
b0a76a1c96 | ||
|
|
f85e457b9b | ||
|
|
119f60516c | ||
|
|
efcb84eaa8 | ||
|
|
6129173fe6 | ||
|
|
ac457282ce | ||
|
|
d813225ac4 | ||
|
|
3475ce7ce6 | ||
|
|
c0229b60c1 | ||
|
|
fbed8d1ea4 | ||
|
|
1fc5d74d8a | ||
|
|
4606fbfd4f | ||
|
|
0ff0e22e56 | ||
|
|
6ea222e5ab | ||
|
|
b8c0ddda6d | ||
|
|
d987400a5b | ||
|
|
6a7757ae2f | ||
|
|
78cc2aab56 |
@@ -1,6 +1,5 @@
|
||||
name: CI
|
||||
|
||||
# 仅分支推送触发:tag 推送交给 release.yml;solo 开发不走 PR,去掉 pull_request 避免同一提交双跑
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
@@ -20,5 +19,7 @@ jobs:
|
||||
run: npx eslint .
|
||||
- name: 类型检查
|
||||
run: npm run typecheck
|
||||
- name: 单元测试
|
||||
run: npm run test
|
||||
- name: 构建
|
||||
run: npm run build
|
||||
|
||||
@@ -19,5 +19,7 @@ jobs:
|
||||
run: npx eslint .
|
||||
- name: 类型检查
|
||||
run: npm run typecheck
|
||||
- name: 单元测试
|
||||
run: npm run test
|
||||
- name: 构建
|
||||
run: npm run build
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.6.6
|
||||
0.6.10
|
||||
@@ -1,5 +1,94 @@
|
||||
# API 层与状态管理规范
|
||||
|
||||
- 所有请求经统一 request 封装(`src/api/request`):注入 JWT、401 统一跳登录、错误统一提示;组件内不直接 `fetch`/裸 axios。
|
||||
- `request` 的 `body` 传**对象**,封装内部统一 `JSON.stringify`;调用方自己 stringify 会双重编码,后端报 `cannot unmarshal string into …`(2026-07-14 ai-settings 开关踩坑)。
|
||||
- 跨页面共享状态才进 Pinia;页面内状态用 `ref`/`computed` 就地管理,不把一切塞 store。
|
||||
- 请求函数返回类型化数据(`Promise<Instance[]>`),错误向上抛由封装层兜底,不在每个调用点重复 try/catch。
|
||||
- 消息提示统一走 `useToast`(`src/composables/useToast`),禁止组件直接 `useMessage`:超长错误(如 OCI 透传)自动折叠成「标题 + 展开详情」,显式传 detail 可自定义折叠内容。
|
||||
- 账单域(round14 起):发票/付款方式 DTO 在 `types/api.ts`,请求走 `api/billing.ts`;发票 PDF 接口带 JWT 不能裸 `<a href>`,统一经 `downloadInvoicePdf`(rawFetch→blob→触发保存)。发票 tab 的 403 不当普通错误弹 toast,转受限卡提示 osp-gateway policy;「未付余额」口径只计 OPEN/PAST_DUE,PAYMENT_SUBMITTED 已提交扣款不计入。
|
||||
- 发票列表按年懒加载(round15 起):`listInvoices(id, year)` 初始拉当年、空则自动往前探,手动逐年加载,连空 2 年停;明细/PDF 预览/付款方式一律弹窗(NModal),不用 NDrawer。对象存储 Archive 层:查看器「下载」在非 Restored 时禁用、「分享」一律禁用(PAR 对 Archive 无效);列表有 Restoring 对象时 30s 静默轮询自动停;公共读桶(visibility=ObjectRead)「复制 URI」变「复制 URL」(对象名整体 encodeURIComponent 拼公网直链)。
|
||||
- Usage API 的成本时序对无出账记录的时段**不返回行**:图表轴不能用返回数据的 key 排序生成,须前端按范围生成完整标签再补零(CostTab `fullLabels()`),否则断轴缺刻度。
|
||||
- 大列表禁止全量拉取(2026-07-21 PAR 复盘):几千行进 `ref` + NDataTable 会全量响应式代理化 + 建树,主线程打满数秒——服务端有游标就走 remote 分页,交互复用 ObjectBrowser 的「游标栈 + 上一页/下一页」模式(游标分页拿不到总数,头部计数用「本页 N(+)」表述);`listPars` 返回 `{items, nextPage}`,每页 100。OCI 写后读有秒级最终一致性(换 IP 后 GetVnic 仍回旧值):变更成功不能只重查一次,要带预期值开限时轮询窗口(VnicPanel `ipExpect` 90s,收敛后再 emit changed 联动父级刷新)。
|
||||
- 表单先上传临时远端资源、再创建业务对象时:上传响应保存 `{cfgId, domainId, url, fileName}`
|
||||
原始元组,用请求序号丢弃迟到响应;替换、放弃表单或提交未引用它时按元组尽力删除,
|
||||
失败静默(孤儿只是自己租户里一张无入口小图)。创建请求引用了它且返回 2xx
|
||||
(含 `201 + setupWarning`)即视为已采用不再清理;清理复杂度须与资源价值匹配,
|
||||
勿为低价值资源建冻结所有权/结果未知协议的状态机(2026-07 IdP 图标精简教训)。
|
||||
域相关异步前置设置未加载完成前禁用提交,后端仍是同一约束的权威校验。
|
||||
- 可重入异步加载(分页、自动刷新、详情弹窗、依赖切换)不经 useAsync 时必须带**请求代次守卫**(`let seq = 0; const n = ++seq; … if (n !== seq) return`),晚到响应丢弃、loading 只由最新请求清;作用域标识(如 cfg/region/bucket)变更时同步重置从属状态(前缀/游标/选中/弹窗)。会创建远端会话的打开流程,过期响应要就地删除刚建的会话(SerialConsolePanel)。(2026-07-22 审查 #5/#6/#10/#17)
|
||||
- 401 登出前比对**发出请求时快照的 token** 与当前 token:不一致说明会话已更新(如 OAuth 绑定回跳),旧令牌的迟到 401 不得登出新会话(request.ts `handleUnauthorized`)。敏感写操作还须遵守下方「会话换发与并发 401」契约。含时间比较的登录态判定用普通函数,不用 computed(时间流逝不触发重算)。(#4/#15)
|
||||
- 固定周期轮询用 `setTimeout` 自链(上一轮 finally 里排下一轮),不用 `setInterval`:慢响应会堆叠请求且可能永久饥饿。(#16)
|
||||
- `window.open` 只能在用户手势内同步调用,异步回调(如签发 PAR 后)现开会被弹窗策略拦截且返回 null:需要新窗口的异步流程在点击处理器里先 `window.open('', '_blank')` 预开、拿到结果后 `location.replace` 导航,失败关闭空白窗;任何 `window.open` 返回 null 都要如实报错并给重试(重试点击是新手势),不得标记成功。(2026-07-22 审查 #19,ObjectBrowser 下载)
|
||||
- 写请求(非 GET)在 `request.ts` 层按 method+url+body 做 **in-flight 去重**(2026-07-23 起):前一发未返回时同 key 复用同一 Promise 不再发第二发,完成即移除;GET 不去重(useAsync seq 后发优先)。这是防连点的系统级兜底,组件级 busy/disabled 仍必须做(见 typescript-vue.md);合法的「连续两次相同写」(极少)需改变 body 或等待前次完成。
|
||||
|
||||
## 场景:会话换发与并发 401
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
后端成功后会令旧 JWT 失效并返回新会话的认证写接口(改密、登录策略、TOTP、
|
||||
身份/通行密钥变更、撤销全部会话及钱包绑定)必须使用本契约。它防止并发旧请求的
|
||||
401 先到时清空即将换发的新会话;不用于普通写接口,也不把所有写请求全局串行化。
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
```ts
|
||||
interface RequestOptions {
|
||||
refreshesSession?: boolean
|
||||
}
|
||||
|
||||
request<SessionRefresh>(path, {
|
||||
method: 'POST',
|
||||
body,
|
||||
refreshesSession: true,
|
||||
})
|
||||
```
|
||||
|
||||
`SessionRefresh` 的成功响应必须包含字符串 `token` 与 `expiresAt`。
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- 有旧 Token 的 `refreshesSession` 请求按“发出时 Token”登记在途换发。
|
||||
- 2xx 响应须在 Promise 返回、换发登记释放前由请求层先写入 Auth Store;组件可为
|
||||
mock 流程重复写同值。
|
||||
- 其他请求的 401 若发现同一旧 Token 仍有换发在途,须等待全部结束后再比较 Store;
|
||||
Token 已变化则只抛业务错误,不登出。
|
||||
- 换发请求自身收到 401 时先幂等退出登记再处理 401,避免两个失败换发互相等待。
|
||||
- 公开登录没有旧 Token,不由请求层提前建立会话;页面仍负责登录后导航。
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
| 条件 | 行为 |
|
||||
| --- | --- |
|
||||
| 2xx 且 `token` / `expiresAt` 合法 | 先 `setSession`,再释放等待者 |
|
||||
| 网络错误、畸形 JSON、非 2xx | 不写 Store,必须释放登记 |
|
||||
| 并发 401 等待后 Token 已变化 | 保留新会话,不跳登录页 |
|
||||
| 等待后仍是请求时 Token | 登出并带当前地址跳转登录页 |
|
||||
| 多个换发请求均返回 401 | 全部拒绝,不死锁,只执行一次有效登出 |
|
||||
|
||||
### 5. Good / Base / Bad Cases
|
||||
|
||||
- Good:改密成功响应与旧列表请求 401 并发,成功响应先落 Store,旧 401 不登出。
|
||||
- Base:没有换发在途的当前 Token 收到 401,按原流程登出。
|
||||
- Bad:仅在组件 `await` 后写 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 双处同步)。
|
||||
@@ -1,59 +0,0 @@
|
||||
# Component Guidelines
|
||||
|
||||
> How components are built in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's component conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What component patterns do you use?
|
||||
- How are props defined?
|
||||
- How do you handle composition?
|
||||
- What accessibility standards apply?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Component Structure
|
||||
|
||||
<!-- Standard structure of a component file -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Props Conventions
|
||||
|
||||
<!-- How props should be defined and typed -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Styling Patterns
|
||||
|
||||
<!-- How styles are applied (CSS modules, styled-components, Tailwind, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
<!-- A11y requirements and patterns -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- Component-related mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 前端目录结构
|
||||
|
||||
> Vue 3 前端工程的目录职责与命名约定。
|
||||
> Vue 3 前端工程(`oci-portal-dash/`)的目录职责与命名约定。
|
||||
|
||||
## 目录职责
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Hook Guidelines
|
||||
|
||||
> How hooks are used in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's hook conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What custom hooks do you have?
|
||||
- How do you handle data fetching?
|
||||
- What are the naming conventions?
|
||||
- How do you share stateful logic?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Custom Hook Patterns
|
||||
|
||||
<!-- How to create and structure custom hooks -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Data Fetching
|
||||
|
||||
<!-- How data fetching is handled (React Query, SWR, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
<!-- Hook naming rules (use*, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- Hook-related mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 技术栈约束
|
||||
|
||||
Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。
|
||||
Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive UI 承担组件视觉(`themeOverrides`),Tailwind 只承载设计 token 与布局工具类。选型对比与理由归档于 `.trellis/tasks/archive/2026-07/07-06-frontend-design-research/`。
|
||||
|
||||
---
|
||||
|
||||
@@ -17,6 +17,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
|
||||
| [Directory Structure](./directory-structure.md) | src 分层、文件命名与全局作用域约定 |
|
||||
| [TypeScript & Vue](./typescript-vue.md) | TS 严格模式与组件写法 |
|
||||
| [Styling & Design Tokens](./styling-design-tokens.md) | Anthropic 风设计 token、字体与反模式清单 |
|
||||
| [Buttons](./buttons.md) | 按钮七变体、尺寸档位与危险两段式守则 |
|
||||
| [API & State](./api-and-state.md) | 请求封装与 Pinia 使用边界 |
|
||||
|
||||
---
|
||||
@@ -37,6 +38,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
|
||||
```bash
|
||||
npm run lint # ESLint(flat config,含 eslint-plugin-vue recommended)+ Prettier
|
||||
npm run typecheck # vue-tsc --noEmit
|
||||
npm run test # vitest(happy-dom),*.test.ts 与被测模块同目录
|
||||
npm run build # 类型检查 + 产物构建
|
||||
```
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Quality Guidelines
|
||||
|
||||
> Code quality standards for frontend development.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's quality standards here.
|
||||
|
||||
Questions to answer:
|
||||
- What patterns are forbidden?
|
||||
- What linting rules do you enforce?
|
||||
- What are your testing requirements?
|
||||
- What code review standards apply?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
<!-- Patterns that should never be used and why -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Required Patterns
|
||||
|
||||
<!-- Patterns that must always be used -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
<!-- What level of testing is expected -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
<!-- What reviewers should check -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -1,51 +0,0 @@
|
||||
# State Management
|
||||
|
||||
> How state is managed in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's state management conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What state management solution do you use?
|
||||
- How is local vs global state decided?
|
||||
- How do you handle server state?
|
||||
- What are the patterns for derived state?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## State Categories
|
||||
|
||||
<!-- Local state, global state, server state, URL state -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## When to Use Global State
|
||||
|
||||
<!-- Criteria for promoting state to global -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Server State
|
||||
|
||||
<!-- How server data is cached and synchronized -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
<!-- State management mistakes your team has made -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 样式与设计 Token 规范
|
||||
|
||||
> Anthropic 风(warm editorial)设计体系:色板、字体、圆角与反模式清单。代码事实源:`src/theme/tokens.ts`(JS 侧唯一来源)与 `src/assets/main.css`(Tailwind `@theme`)。
|
||||
> Anthropic 风(warm editorial)设计体系:色板、字体、圆角与反模式清单。代码事实源:`oci-portal-dash/src/theme/tokens.ts`(JS 侧唯一来源)与 `oci-portal-dash/src/assets/main.css`(Tailwind `@theme`)。
|
||||
|
||||
## 基本规则
|
||||
|
||||
@@ -64,6 +64,23 @@
|
||||
|
||||
大圆角(>8px 的按钮/卡片)、任何渐变(含品牌色渐变文字)、玻璃拟态/毛玻璃、霓虹光晕、彩色大投影、emoji 当图标、深紫深蓝"AI 感"配色、满屏色块卡片、弹跳动效。
|
||||
|
||||
## 结构复用约定(2026-07-21 样式一致性收敛后固化)
|
||||
|
||||
- **面板头**:一律用 `PanelHeader`(title + desc + 默认插槽放右侧动作 + `#title-extra` 放徽标/计数),不再手写 `border-b … px-4.5 py-3.5` 头部;结构确实特殊的(带面包屑/筛选工具条)保持自定义但 padding 必须 `px-4.5 py-3.5`。面板一级标题 `text-sm font-semibold`,`text-[13px] font-semibold` 只允许出现在面板内的二级小节/行内标题。
|
||||
- **空态/错误态**:整卡(panel 级)一律 `EmptyCard`(错误态 `title="…加载失败" :note="错误信息"`);表格内空态交给 NDataTable 自带 empty;手写灰字仅限行内小空位(如 IPv6「无」)。
|
||||
- **浮层投影**:下拉/确认弹层/整页提示卡统一 `shadow-overlay`(@theme `--shadow-overlay`),圆角不超过 `rounded-lg`;禁止再写 `shadow-[0_18px…]` 一类逐处任意值。
|
||||
- **恒暗页面**(网页 VNC 等):色值从 `@/theme/tokens` 的 `darkTokens` 取,不硬编码 hex;需要给组件 prop 传具体色时同样从 tokens/darkTokens 引。
|
||||
|
||||
## 移动端形态约定(2026-07-21 移动端适配 B0-B4 固化)
|
||||
|
||||
- **断点**:`md`(768px)为移动/桌面切换线,JS 层条件渲染一律 `useIsMobile()`(与 CSS 断点同源);`lg` 仅做网格降列。
|
||||
- **弹窗**:表单弹窗一律 `FormModal`(移动端自动全屏 + 页脚常驻);直接用原生 NModal 的(详情查看类)接 `useMobileModal(width)` 取 `:style` 与 `:class`,全屏布局规则 `.form-modal-mobile` 在 `main.css`(全局,teleport 与跨 chunk 都够得到)。组件 scoped 里给弹窗内容限高须包 `@media (min-width: 768px)`——main.css 先于组件样式加载,同特异性覆盖不可靠。
|
||||
- **表格**:所有 NDataTable 必配 `scroll-x`(内容合计宽);受控分页 reactive 对象加 `get simple() { return isMobile.value }`(移动端页码槽放不下);高频列表页(租户/实例式)另供 `MobileCardList` 卡片形态,表格 `v-if="!isMobile"`。
|
||||
- **筛选器**:多控件筛选一律包 `FilterBar`(桌面 wrap、移动横滚+右缘渐隐);面板头单控件筛选桌面放 `PanelHeader` 插槽、移动独立行(`v-if="isMobile"` 的 `border-b px-4.5 py-2.5` 全宽行)。
|
||||
- **详情页 hero**:panel 内「返回链接 → 标题行(page-title+徽标+flex-1+按钮组)→ 移动折叠 toggle → meta grid」四段式;操作按钮组包 `flex items-center gap-2 max-md:w-full`,移动端整组独立成行。
|
||||
- **导航**:顶级页面进 `layouts/navItems.ts`(侧栏/底栏/「更多」抽屉唯一来源);新增页面须归入 `mobileTabNames`(高频四项)或 `mobileMoreNames`。
|
||||
- **构建**:新增大体积三方库须在 `vite.config.ts` 的 `vendorChunk` 归入命名组(office 系深依赖靠 importer 链上溯),否则聚成匿名 index 块逃逸 PWA 预缓存排除(`globIgnores: office-*`)并可能触发 chunk 告警。
|
||||
|
||||
## Common Mistake: NModal 宽度不能用 Tailwind class
|
||||
|
||||
**Symptom**:`<NModal preset="card" class="w-[480px]">` 弹窗实际渲染为全宽。
|
||||
@@ -106,6 +123,14 @@
|
||||
|
||||
验证方法:build 后 `grep -o 'html\.dark[^}]*}' dist/assets/<view>-*.css` 核对编译产物选择器,或线上 DevTools 看 computed style 是否命中。
|
||||
|
||||
## Common Mistake:AppInputNumber 全局样式假定 suffix 只有步进两键
|
||||
|
||||
**Symptom**:`AppInputNumber` 加 `clearable` 后步进箭头挤出输入框右缘(2026-07 抢机表单「引导卷」实例)。
|
||||
|
||||
**Cause**:`main.css` 的 `.app-input-number` 把 `.n-input__suffix` 设为 26px 宽 2 行网格,并用 `:last-child` / `:nth-last-child(2)` 定位 add / minus;naive-ui 在 `clearable` 时于 suffix 首位额外渲染 `.n-base-clear`(无值时也存在),网格自动放置把它排进隐式第 2 列,竖栏被撑爆。
|
||||
|
||||
**Fix / Prevention**:已在 `main.css` 全局处理——`.n-base-clear` 绝对定位脱离网格流悬浮竖栏左侧,`.n-input:has(.n-base-clear)` 的 wrapper `padding-right` 加宽避让。今后给 `AppInputNumber` 追加会向 suffix 注入元素的 prop(loading / 自定义 suffix 插槽等)时,须同步检查该网格假设。
|
||||
|
||||
## 组件速查:NQrCode 去白底
|
||||
|
||||
NQrCode 自带 inline `background-color:#FFF; padding:12px`(props 可控)。两主题都不垫任何色块的方案:`:padding="0" background-color="transparent"` + `:color="app.dark ? '#f5f4ed' : '#141413'"`——暗色下反色渲染(浅模块透明底),主流扫码器(iOS 相机 / Google Lens / 新版 ML Kit)可识别,扫不动时弹窗内的手动密钥是兜底。color 是响应式 prop,切主题自动重绘。(历史方案「html.dark 下垫 #f5f4ed 浅底」因用户不接受色块框已废弃。)
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# Type Safety
|
||||
|
||||
> Type safety patterns in this project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
<!--
|
||||
Document your project's type safety conventions here.
|
||||
|
||||
Questions to answer:
|
||||
- What type system do you use?
|
||||
- How are types organized?
|
||||
- What validation library do you use?
|
||||
- How do you handle type inference?
|
||||
-->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Type Organization
|
||||
|
||||
<!-- Where types are defined, shared types vs local types -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
<!-- Runtime validation patterns (Zod, Yup, io-ts, etc.) -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
<!-- Type utilities, generics, type guards -->
|
||||
|
||||
(To be filled by the team)
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
<!-- any, type assertions, etc. -->
|
||||
|
||||
(To be filled by the team)
|
||||
@@ -14,3 +14,9 @@
|
||||
- `v-for` 必须带 `key`,且不与 `v-if` 写在同一元素上。
|
||||
- 组件内样式一律 `scoped`;跨组件复用样式上提到全局 token 或工具类。
|
||||
- 复用逻辑抽 composable(`useXxx` 命名,放 `composables/`);单文件组件函数遵循 ≤30 行的项目约定。
|
||||
- 二次确认一律用 `components/ConfirmPop.vue`(批次8 起替代裸 NPopconfirm):kind 分 danger/warn/plain 三态,`onConfirm` 返回 Promise 时确认按钮自动 loading 且弹层锁定(onConfirm 内部必须自行 catch,不得向外抛)。`dialog.warning` 例外仅限两类:多分支抉择(如终止实例选是否保留引导卷)与下拉菜单项触发(popover 无锚点),此时 onPositiveClick 返回 Promise 让确认键自带 loading;NDialog 其余只用于结果展示。
|
||||
- 写操作触发器(按钮/开关)必须有 in-flight 防连点(按钮交互一致性任务 2026-07-23 起):单动作用 busy ref;列表行内用 `ref(new Set<id>())` 按行 loading/disabled;同行多动作用 `Map<id, action>` 区分,避免错按钮亮 loading。中断性电源操作(停止/重启)与改登录策略类操作必须 ConfirmPop(warn)确认。
|
||||
- 文件类型图标用 `components/objectstorage/FileIcon.vue`(Catppuccin Icons):图标数据由 `node scripts/gen-file-icons.mjs` 从 devDependency `@iconify-json/catppuccin` 提取到 `fileIcons.ts`(生成文件勿手改);新增类型改脚本 PICK 列表后再生成,禁止整包 import iconify JSON 进 bundle。
|
||||
- highlight.js 高亮输出的配色是全局 `.code-hl` 类(`assets/main.css`,引用设计 token 明暗自适应),给容器加该类即可,勿在组件 scoped 里重复定义;markdown 渲染必须 marked + DOMPurify 消毒后再 v-html,两库一律动态 import 保持独立 chunk。
|
||||
- 对象内容数据面分两路(round11 起):预览/编辑走面板中转 `getObjectContent`/`putObjectContent`(GET ≤20MB / PUT ≤5MB,保存以 If-Match 防并发,412 走覆盖确认),**不签发 PAR**;分享/下载/上传仍走 PAR 直连,勿把大流量改经面板。对象查看统一入口是 `ObjectViewerModal`(预览编辑合一,左内容右信息),office/pdf 用 `@vue-office/*` 且必须 `defineAsyncComponent` 动态加载保持独立 chunk。
|
||||
- 在线编辑器是 CodeMirror 6(`viewer/TextEditPane.vue`,round13 起):核心/语言包/legacy-modes 全部动态 import;自定义 HighlightStyle **不带 fallback**(否则被 basicSetup 内置 defaultHighlightStyle 压过),配色引用设计 token 与 `.code-hl` 语义一致;hljs 只服务只读渲染(预览/md),不再用于编辑。格式化走 `objectstorage/format.ts`(json 原生,其余 prettier standalone 插件按需动态 import)。
|
||||
|
||||
@@ -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.
|
||||
@@ -24,4 +24,4 @@
|
||||
- **并发安全**:用户可能同时在编辑器里改文件,所有写入必须带上次读到的 etag,冲突时以用户改动为基准重新套用变更,绝不无条件覆盖。
|
||||
- **链接纪律**:交付给人的永远是 claude.ai/design 链接;`serve_url`(claudeusercontent.com)是带项目令牌的短时链接,仅供自动化浏览器验证,不得外发。
|
||||
- **平台边界**:Claude Design 没有本地 skill/plugin 市场与内部生成 agent,设计由当前会话直接产出;风格底座与评审职责由 token 清单(含反模式)与渲染验证循环承接。
|
||||
- **导出衔接**:定稿 .dc.html 即结构化 HTML/CSS,实现阶段按 `.trellis/frontend/` 前端规范翻译成 Vue SFC + Naive UI themeOverrides,token 一一对应。
|
||||
- **导出衔接**:定稿 .dc.html 即结构化 HTML/CSS,实现阶段按 `.trellis/spec/frontend/` 前端规范翻译成 Vue SFC + Naive UI themeOverrides,token 一一对应。
|
||||
|
||||
@@ -1,97 +1,24 @@
|
||||
# Thinking Guides
|
||||
# 项目 Guides
|
||||
|
||||
> **Purpose**: Expand your thinking to catch things you might not have considered.
|
||||
|
||||
---
|
||||
|
||||
## Why Thinking Guides?
|
||||
|
||||
**Most bugs and tech debt come from "didn't think of that"**, not from lack of skill:
|
||||
|
||||
- Didn't think about what happens at layer boundaries → cross-layer bugs
|
||||
- Didn't think about code patterns repeating → duplicated code everywhere
|
||||
- Didn't think about edge cases → runtime errors
|
||||
- Didn't think about future maintainers → unreadable code
|
||||
|
||||
These guides help you **ask the right questions before coding**.
|
||||
|
||||
---
|
||||
> 跨仓库的工作纪律与流程约定;各层编码规范见 `oci-portal/backend/` 与 `oci-portal-dash/frontend/`。
|
||||
|
||||
## Available Guides
|
||||
|
||||
| Guide | Purpose | When to Use |
|
||||
|-------|---------|-------------|
|
||||
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
|
||||
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
|
||||
| Guide | 内容 | 何时读 |
|
||||
|-------|------|--------|
|
||||
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 |
|
||||
|
||||
## AI 交叉评审结果核验
|
||||
|
||||
- 评审称「用户输入可能是恶意的」→ 先查实际数据源:内部清单?用户配置?外部 API?
|
||||
- 评审标「缺少校验」→ 数据是否来自可信内部源?
|
||||
- 评审称「行为变化」→ 读代码注释——是否本就是有意设计?
|
||||
- 评审指测试有 bug → 心里删掉被测功能,测试还过吗?过 = 同义反复测试
|
||||
|
||||
常见误报模式:信任边界混淆(把内置数据当外部输入)、无视设计注释、变量误读(未追到实际定义)。
|
||||
|
||||
**核验规则**:每条 CRITICAL/WARNING 结论必须对照实际代码核验后再排优先级;AI 评审按 ~35% 误报率做预算。
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference: Thinking Triggers
|
||||
|
||||
### When to Think About Cross-Layer Issues
|
||||
|
||||
- [ ] Feature touches 3+ layers (API, Service, Component, Database)
|
||||
- [ ] Data format changes between layers
|
||||
- [ ] Multiple consumers need the same data
|
||||
- [ ] You're not sure where to put some logic
|
||||
- [ ] You are adding an event kind, JSONL record, RPC payload, or config field
|
||||
- [ ] UI / command code starts casting raw payload fields directly
|
||||
|
||||
→ Read [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md)
|
||||
|
||||
### When to Think About Code Reuse
|
||||
|
||||
- [ ] You're writing similar code to something that exists
|
||||
- [ ] You see the same pattern repeated 3+ times
|
||||
- [ ] You're adding a new field to multiple places
|
||||
- [ ] **You're modifying any constant or config**
|
||||
- [ ] **You're creating a new utility/helper function** ← Search first!
|
||||
- [ ] Two files read the same untyped payload field with local casts
|
||||
- [ ] Multiple branches update the same derived state from `kind` / `action`
|
||||
|
||||
→ Read [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md)
|
||||
|
||||
### When Verifying AI Cross-Review Results
|
||||
|
||||
- [ ] Reviewer claims "user input can be malicious" → Check the actual data source (internal manifest? user config? external API?)
|
||||
- [ ] Reviewer flags "missing validation" → Is the data from a trusted internal source?
|
||||
- [ ] Reviewer says "behavior change" → Read the code comments — is it intentional design?
|
||||
- [ ] Reviewer identifies a "bug" in test → Mentally delete the feature being tested — does the test still pass? If yes → tautological test
|
||||
|
||||
**Common AI reviewer false-positive patterns**:
|
||||
1. **Trust boundary confusion**: Treating internal data (bundled JSON manifests) as untrusted external input
|
||||
2. **Ignoring design comments**: Flagging intentional behavior documented in code comments as bugs
|
||||
3. **Variable misreading**: Not tracing a variable to its actual definition (e.g., Map keyed by path vs name)
|
||||
|
||||
**Verification rule**: Every CRITICAL/WARNING finding must be verified against the actual code before prioritizing. Budget ~35% false-positive rate for AI reviews.
|
||||
|
||||
---
|
||||
|
||||
## Pre-Modification Rule (CRITICAL)
|
||||
|
||||
> **Before changing ANY value, ALWAYS search first!**
|
||||
|
||||
```bash
|
||||
# Search for the value you're about to change
|
||||
grep -r "value_to_change" .
|
||||
```
|
||||
|
||||
This single habit prevents most "forgot to update X" bugs.
|
||||
|
||||
---
|
||||
|
||||
## How to Use This Directory
|
||||
|
||||
1. **Before coding**: Skim the relevant thinking guide
|
||||
2. **During coding**: If something feels repetitive or complex, check the guides
|
||||
3. **After bugs**: Add new insights to the relevant guide (learn from mistakes)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Found a new "didn't think of that" moment? Add it to the relevant guide.
|
||||
|
||||
---
|
||||
|
||||
**Core Principle**: 30 minutes of thinking saves 3 hours of debugging.
|
||||
2026-07 已删除上游模板自带的两个通用 thinking guide(code-reuse / cross-layer):内容为通用常识与 Trellis 自身开发案例,与本项目无关。项目教训一律直接沉淀到对应层的规范文件,不建通用思维文档。
|
||||
|
||||
+20
-19
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
|
||||
- 1.0 Create task `[required · once]` (only after task-creation consent)
|
||||
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
|
||||
- 1.2 Research `[optional · repeatable]`
|
||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix (sub-agent-dispatch platforms only; inline platforms skip)
|
||||
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Grok, Kimi Code (sub-agent-dispatch platforms only; inline platforms skip)
|
||||
- 1.4 Activate task `[required · once]` (review gate, then `task.py start`; status → in_progress)
|
||||
- 1.5 Completion criteria
|
||||
|
||||
@@ -220,7 +220,7 @@ Inline mode: skip jsonl curation; Phase 2 reads artifacts/specs via `trellis-bef
|
||||
therefore must cover every required step from implementation through
|
||||
commit, including Phase 3.3 spec update and Phase 3.4 commit. -->
|
||||
|
||||
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including class-2 Codex/Gemini/Qoder/Copilot/ZCode/Reasonix/Trae and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions.
|
||||
Sub-agent dispatch protocol applies to all platforms and all sub-agents, including native Codex `SubagentStart` context injection with child-side pull fallback, class-2 Gemini/Qoder/Copilot/Reasonix/Trae/Grok/Kimi Code, hook-backed ZCode/Snow, and `trellis-research`: every dispatch prompt starts with `Active task: <task path from task.py current>` before role-specific instructions. On Grok Build, use `spawn_subagent` with `subagent_type` set to the Trellis agent name (e.g. `trellis-implement`). On Kimi Code, dispatch the built-in `coder` / `explore` sub-agent with the matching `.kimi-code/skills/trellis-<role>/SKILL.md` instructions.
|
||||
|
||||
[workflow-state:in_progress]
|
||||
Tools: `trellis-implement` / `trellis-research` are sub-agent types only (Task/Agent tool, NOT Skill; there is no skill by these names). `trellis-update-spec` is a skill. `trellis-check` exists as both; prefer the Agent form when verifying after code changes.
|
||||
@@ -272,13 +272,13 @@ Code committed. Run `/trellis:finish-work`; if dirty, return to Phase 3.4 first.
|
||||
|
||||
When a user request matches one of these intents inside an active task, route first, then load the detailed phase step if needed.
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
- Planning or unclear requirements -> `trellis-brainstorm`.
|
||||
- `in_progress` implementation/check -> dispatch `trellis-implement` / `trellis-check`.
|
||||
- Repeated debugging -> `trellis-break-loop`; spec updates -> `trellis-update-spec`.
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
@@ -353,7 +353,7 @@ Return to this step whenever requirements change and revise the relevant artifac
|
||||
|
||||
Research can happen at any time during requirement exploration. It isn't limited to local code — you can use any available tool (MCP servers, skills, web search, etc.) to look up external information, including third-party library docs, industry practices, API references, etc.
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
Spawn the research sub-agent:
|
||||
|
||||
@@ -361,11 +361,11 @@ Spawn the research sub-agent:
|
||||
- **Task description**: Research <specific question>
|
||||
- **Key requirement**: Research output MUST be persisted to `{TASK_DIR}/research/`
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. (For `codex-inline` this avoids the `fork_turns="none"` isolation that prevents `trellis-research` sub-agents from resolving the active task path.)
|
||||
Do the research in the main session directly and write findings into `{TASK_DIR}/research/`. `codex-inline` is the explicit mode that keeps work in the main session.
|
||||
|
||||
[/codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
@@ -380,7 +380,7 @@ Brainstorm and research can interleave freely — pause to research a technical
|
||||
|
||||
#### 1.3 Configure context `[required · once]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
Curate `implement.jsonl` and `check.jsonl` so the Phase 2 sub-agents get the right spec/research context. These files were seeded on `task create` with a single self-describing `_example` line; your job here is to fill in real entries.
|
||||
|
||||
@@ -425,7 +425,7 @@ Ready gate: both `implement.jsonl` and `check.jsonl` must contain at least one r
|
||||
|
||||
Skip this step only when both files already have real curated entries.
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
@@ -458,11 +458,11 @@ If `task.py start` errors with a session-identity message (no context key from h
|
||||
| `design.md` exists (complex tasks) | ✅ |
|
||||
| `implement.md` exists (complex tasks) | ✅ |
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
| `implement.jsonl` and `check.jsonl` each contain at least one real curated entry (seed row does not count) | ✅ |
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
---
|
||||
|
||||
@@ -472,21 +472,22 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
|
||||
|
||||
#### 2.1 Implement `[required · repeatable]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-implement`
|
||||
- **Task description**: Implement the reviewed task artifacts, consulting materials under `{TASK_DIR}/research/`; finish by running project lint and type-check
|
||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-implement` sub-agent and must implement directly, not spawn another `trellis-implement` / `trellis-check`.
|
||||
|
||||
The platform hook/plugin auto-handles:
|
||||
- Reads `implement.jsonl` and injects referenced spec/research files into the agent prompt
|
||||
- Injects `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
- For Codex, `SubagentStart` supplies native context injection; the agent profile keeps child-side loading as the fallback
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, CodeBuddy, Droid, Pi, ZCode, Snow, Oh My Pi]
|
||||
|
||||
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||
[Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
Spawn the implement sub-agent:
|
||||
|
||||
@@ -498,7 +499,7 @@ The pull-based sub-agent definition auto-handles the context load requirement:
|
||||
- Resolves the active task with `task.py current --source`, then reads `prd.md`, `design.md` if present, and `implement.md` if present
|
||||
- Reads `implement.jsonl` and requires the agent to load each referenced spec/research file before coding
|
||||
|
||||
[/codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
|
||||
[/Gemini, Qoder, Copilot, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
[Kiro]
|
||||
|
||||
@@ -526,13 +527,13 @@ The platform prelude auto-handles the context load requirement:
|
||||
|
||||
#### 2.2 Quality check `[required · repeatable]`
|
||||
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
Spawn the check sub-agent:
|
||||
|
||||
- **Agent type**: `trellis-check`
|
||||
- **Task description**: Review all code changes against specs and task artifacts; fix any findings directly; ensure lint and type-check pass
|
||||
- **Dispatch prompt guard**: Tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||
- **Dispatch prompt guard**: The prompt MUST start with `Active task: <task path>`, then tell the spawned agent it is already the `trellis-check` sub-agent and must review/fix directly, not spawn another `trellis-check` / `trellis-implement`.
|
||||
|
||||
The check agent's job:
|
||||
- Review code changes against specs
|
||||
@@ -540,7 +541,7 @@ The check agent's job:
|
||||
- Auto-fix issues it finds
|
||||
- Run lint and typecheck to verify
|
||||
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
|
||||
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Snow, Reasonix, Trae, Grok, Kimi Code]
|
||||
|
||||
[codex-inline, Kilo, Antigravity, Devin]
|
||||
|
||||
|
||||
+146
@@ -2,6 +2,152 @@
|
||||
|
||||
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
|
||||
|
||||
## [0.8.3]
|
||||
|
||||
### Added
|
||||
|
||||
- 登录页新增通行密钥与 Web3 钱包免密登录;设置 · 安全重构为密码登录、免密登录、登录方式配置与活跃会话分区,支持管理通行密钥、绑定钱包,并查看及定点撤销其他设备会话
|
||||
|
||||
### Changed
|
||||
|
||||
- 全面统一按钮变体与按下态,删除、停用、实例电源操作等写操作统一补齐二次确认、加载态和防重复触发;相同写请求在飞行期复用同一结果
|
||||
- IAM 用户新建 API Key 后均可切换为面板签名凭据,切换到其他用户时明确提示权限变化并同步刷新签名用户状态
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复敏感操作换发新令牌后,迟到的旧请求 401 可能清除当前新会话;现在仅当失败请求使用的仍是当前令牌时才退出登录
|
||||
|
||||
## [0.8.2]
|
||||
|
||||
### Added
|
||||
|
||||
- IAM 用户新增 API Keys 分栏管理弹窗,支持查看指纹与 OCI CLI 配置、创建(每用户最多 3 把)和逐把删除;新建私钥仅在本次弹窗中可下载,当前签名用户可将新 Key 切换为面板签名凭据
|
||||
|
||||
### Changed
|
||||
|
||||
- IAM 用户重置密码新增二次确认,避免误触后立即生成一次性新密码
|
||||
|
||||
## [0.8.1]
|
||||
|
||||
### Added
|
||||
|
||||
- SAML IdP 创建支持将不超过 1 MB 的 Logo 上传到身份域公共图片存储并预览;替换或放弃表单时会尽力清理未采用图标
|
||||
|
||||
### Changed
|
||||
|
||||
- 对象存储 PAR 分享链接有效期上限由 30 天扩展至 100 年,并新增 1 年 / 10 年快捷选项
|
||||
- 总览成本改为按币种分开展示:主币种保留趋势图,其他币种单列合计,不再跨币种相加
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复 OAuth 绑定回跳与会话过期处理:优先接收新令牌,旧请求迟到的 401 不再清除新会话;当前会话失效时保留原路径跳转登录
|
||||
- 修复安全设置与 OAuth 登录方式并发保存时互相回滚或复活已删除配置,改为字段级更新并合并在途修改
|
||||
- 修复对象存储切换租户 / 区域 / 桶后仍残留旧路径、游标、选中项及 PAR 列表,避免对同名资源执行错误操作;快速切换对象时旧内容 / ETag 不再覆盖当前预览
|
||||
- 修复未知大小及大文件下载因异步打开窗口被浏览器拦截却仍显示成功;现预开下载窗口,拦截时明确提示并支持重试
|
||||
- 修复快速切换实例时旧串行控制台会话接入当前面板,以及日志翻页 / 自动刷新 / 连续查看详情时迟到响应覆盖当前数据
|
||||
- 修复抢机任务编辑时把剩余台数误作目标台数,并避免慢接口下任务列表轮询请求重叠
|
||||
- 修复创建 SAML IdP 时身份设置未按所选身份域读取、异步响应串单和重复提交;JIT 后续配置状态不确定时改为显示关联请求警告
|
||||
|
||||
## [0.8.0]
|
||||
|
||||
### Added
|
||||
|
||||
- 新增对象存储管理:按租户 / 区域 / 区间创建存储桶(可选可见性、存储层和版本控制),后续可更新可见性 / 版本控制,并支持非空桶后台清空删除与存储用量展示;对象浏览支持虚拟目录、本层搜索、游标分页、批量操作、Archive 恢复及上传 / 下载任务队列
|
||||
- 新增对象内容预览与在线编辑:支持图片、文本 / 代码、Markdown、SVG、DOCX、XLSX、PDF 及实验性 PPTX 预览;CodeMirror 提供语法高亮与搜索,支持格式美化、`If-Match` 冲突保护、重命名、分享和下载
|
||||
- 新增对象存储 PAR 分享管理:支持对象级只读 / 读写链接和桶级读写(`AnyObjectReadWrite`)链接、自定义 1–720 小时有效期、游标分页、单条 / 全部删除
|
||||
- 租户详情新增「发票」页:汇总未付与年度金额,按年加载发票,查看费用明细、PDF 预览 / 下载、默认付款方式并在线付款;对无权限、非 PAYG 与账单平台不适用账号提供专门说明
|
||||
- 网络页新增保留公网 IP 管理;创建实例和附加 VNIC 支持临时 / 保留 / 不分配三种公网 IPv4 方式,主 / 次网卡可改用临时或保留 IP
|
||||
- VCN 详情新增便捷操作与安全列表规则编辑,可一键启用 IPv6 / 放行默认规则,并行内新增、修改、删除入站与出站规则
|
||||
- 成本分析新增「今天」小时视图,以及按服务展开产品(SKU)明细;实例列表新增终止操作
|
||||
- 新增 PWA:HTTPS 下可添加到主屏幕并以独立窗口运行,Service Worker 自动更新且不缓存 `/api/*`、`/ai/*` 实时请求
|
||||
|
||||
### Changed
|
||||
|
||||
- 全面适配 ≤767px 移动端:侧栏改为「总览 / 租户 / 实例 / 日志 / 更多」底栏,租户与实例列表卡片化,详情信息可折叠,详情 Tab 与配额筛选器可横向滚动,表单弹窗全屏并适配安全区
|
||||
- AI 网关重构为「密钥与渠道 / 接入与模型」双 Tab;渠道名称可悬停查看关联租户并跳转租户详情
|
||||
- 区间选择器改为树形层级展示并统一用于实例、网络、存储;多处确认操作改为带语义提示和防重复提交状态的确认气泡
|
||||
- 设置页通知开关、安全数值项与抢机熔断阈值改为失焦 / 切换即时保存
|
||||
- Office 预览、CodeMirror、Prettier、Markdown / 高亮等大型依赖按用途分包并按需加载;Office 大块不进入 PWA 首次预缓存
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复成本分析「近 14 天 / 近 30 天」切换未把所选时间范围传给接口,结果不随选择变化
|
||||
- 修复多 VNIC 实例流量曲线只读取首块网卡、时间轴被新网卡短序列截断;现按日期聚合全部 VNIC
|
||||
- 修复主 / 次网卡更换公网 IP 后详情仍显示旧地址;在 90 秒窗口内轮询至新地址收敛,并联动刷新实例顶部信息
|
||||
- 修复多处异步确认操作弹层挂起且可重复提交;确认按钮现展示进行态并锁定重复操作
|
||||
- 修复区间选择器丢失父子层级、长名称溢出,以及回传日志租户选择器被外部宽度样式压窄 / 越界
|
||||
- 补齐 VNIC、卷挂载、IPv6 等既有写操作的系统日志中文动作说明,上述路由不再显示「-」
|
||||
- 修复面板内表格圆角 / 阴影叠加造成的四角露缝,并补齐窄屏表格的横向滚动提示
|
||||
|
||||
## [0.7.3]
|
||||
|
||||
### Added
|
||||
|
||||
- 实例规格表单配额判定改为数据驱动(shape `quotaNames` × compute limits,按行 scope 区分 AD / 区域限额),删除硬编码映射,覆盖全部带配额名的 shape;选定可用域后 Shape 选项按该 AD 配额禁用并标注「该 AD 无配额」,选定 Shape 后可用域选项禁用 / 标注无配额 AD
|
||||
- 创建实例可用域留空且默认 AD-1 对所选 shape 无配额时,自动落位首个有配额的可用域(字段可见可改;抢机表单保持留空 = 轮询语义)
|
||||
|
||||
### Changed
|
||||
|
||||
- 创建实例默认规格 4 OCPU / 24 GB → 2 OCPU / 12 GB(创建与抢机共用表单)
|
||||
- 实例表单(创建 / 调整规格 / 抢机)去除「免费额度」「永久免费」标注与「容量不足可改用抢机任务」「A1 免费额度上限」等提示;免费 shape 仍置顶排序
|
||||
- 回传日志表格移除最大高度限制,行为与系统日志 / AI 调用一致;页脚与通知设置说明同步「创建不回传,避免抢机噪声」
|
||||
- 创建实例失败 / 部分成功提示改为「短标题 + 展开详情」形态,逐台错误进详情块、可复制
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复批量接口全部失败(HTTP 500 + `errors` 数组)时提示只显示「请求失败(500)」:统一请求层现解析 `errors` 数组,展示具体失败原因
|
||||
|
||||
## [0.7.2]
|
||||
|
||||
### Added
|
||||
|
||||
- 设置页 AI Tab「Responses 直通」面板新增「上游无响应预算」输入(30..900 秒,失焦 / 回车提交,即时生效),与流式保险丝同面板并网格对齐
|
||||
- 代理列表新增「状态」列(检测中 / 正常 / 异常):与地区同源于经代理链路的出口探测,探测失败即视为异常,「重测」联动刷新;页脚说明同步
|
||||
|
||||
### Changed
|
||||
|
||||
- AI 调用日志 Tokens(入/出)列加宽并禁止换行,大用量流式行不再折行;原「流式保险丝」独立面板并入「Responses 直通」
|
||||
|
||||
## [0.7.1]
|
||||
|
||||
### Added
|
||||
|
||||
- 租户详情 · 审计日志新增搜索框(标签式查询框,样式对齐配额页):500ms 防抖,回车 / 清空即时生效且关键字未变不发起重复请求;匹配事件 / 资源 / 操作者等可见字段,不区分大小写、支持 `*` 通配
|
||||
- 审计分页状态行新增「已回溯至 …」进度提示;搜索命中稀疏时自动补批封顶(2 次)后停驻,由「加载更早」按钮显式继续,不再无人值守回溯整个保留期
|
||||
|
||||
### Changed
|
||||
|
||||
- 审计页脚说明同步数据源变更:每批约 200 条、搜索匹配可见字段;详情弹窗原始 JSON 来源标注改为「来自 OCI 审计日志」
|
||||
|
||||
### Fixed
|
||||
|
||||
- 修复审计列表加载中刷新 / 更换关键字时,在途旧响应混入新信息流的竞态(代次校验丢弃过期响应)
|
||||
|
||||
## [0.7.0]
|
||||
|
||||
### Added
|
||||
|
||||
- 设置页新增「AI」Tab,集中承载 AI 网关运行时设置:
|
||||
- 流式保险丝:开关 + 阈值输入(KB),关闭时输入框禁用;修改即时生效
|
||||
- grok 服务端搜索工具:`web_search` / `x_search` 默认注入双开关(仅 `xai.` 前缀模型生效,请求已带同名工具不覆盖)
|
||||
- 模型治理:过滤弃用开关与模型黑名单管理(列表 / 移出 / 添加)
|
||||
- 黑名单「添加模型」弹窗:聚合全部渠道的可用模型,按能力分组(对话 / 向量 / 重排 / 语音)、关键字过滤、行内拉黑、已拉黑置灰;目录口径随「过滤弃用」开关
|
||||
|
||||
### Changed
|
||||
|
||||
- 「过滤弃用」开关与模型黑名单管理自 AI 网关页迁入设置 · AI;AI 网关「可用模型」列表移除 ✕ 拉黑入口(添加统一走设置页弹窗),渠道「模型列表」弹窗内的拉黑保留
|
||||
|
||||
### Fixed
|
||||
|
||||
- 实例详情网卡(VNIC):附加成功后按「期望卡数 +1」限时轮询(≤90s),新卡自动出现无需手动刷新页面;IPv6 添加 / 删除仅在网卡 ATTACHED 状态提供,修复 DETACHED 网卡仍可点「+ IPv6」;脚注移除误导的 dhclient / netplan 说明(OCI DHCPv4 只服务主网卡)
|
||||
|
||||
## [0.6.0]
|
||||
|
||||
### Added
|
||||
|
||||
- AI 网关渠道表格新增「模型数量」列(与模型列表同口径:排除黑名单、随「过滤弃用」开关)
|
||||
- 渠道「模型列表」弹窗(替代原「同步模型」按钮,同步入口迁入弹窗):模型按能力分组展示(对话 / 向量 / 重排 / 语音),支持关键字过滤;每行可「测试」(通过即标记「验证模型」徽章并置渠道可用,后续探测优先使用)与「拉黑」(全局黑名单);弃用模型降灰标注,非对话模型不提供测试;操作按钮悬停行时着色,保持列表安静
|
||||
|
||||
## [0.5.1]
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -23,7 +23,7 @@ oci-portal-dash:OCI Portal 前端(Vue 3 + Vite + TypeScript + Naive UI + Tai
|
||||
## 质量门(提交前必过)
|
||||
|
||||
```bash
|
||||
npm run lint && npm run typecheck && npm run build
|
||||
npm run lint && npm run typecheck && npm run test && npm run build
|
||||
```
|
||||
|
||||
## 文档同步
|
||||
|
||||
@@ -18,6 +18,7 @@ npm run dev # http://localhost:5173,默认 mock 模式开箱可视
|
||||
```bash
|
||||
npm run lint # ESLint + Prettier(带 --fix)
|
||||
npm run typecheck # vue-tsc --noEmit
|
||||
npm run test # vitest 单元测试(happy-dom 环境)
|
||||
npm run build # 类型检查 + 产物构建到 dist/
|
||||
```
|
||||
|
||||
@@ -28,6 +29,10 @@ npm run build # 类型检查 + 产物构建到 dist/
|
||||
1. **静态直出**:`dist/` 交给 Caddy/nginx 伺服,`/api/*`、`/ai/*` 反代到后端
|
||||
2. **单文件嵌入**:产物经后端 `go:embed` 打进单个二进制。本仓库 Release 流程(tag `v*`)自动构建并发布 `dist.zip`,后端 Release 流程从这里下载嵌入;本地则 `npm run build` 后把 `dist/` 内容拷入后端 `internal/webui/dist/`
|
||||
|
||||
### PWA
|
||||
|
||||
构建产物自带 PWA(vite-plugin-pwa,`manifest.webmanifest` + Service Worker),HTTPS 部署下手机浏览器可「添加到主屏幕」以独立窗口运行;前端发版后用户刷新即自动更新(`registerType: autoUpdate`)。Service Worker 只预缓存应用壳(约 4.6MB,office 预览大块按需缓存),`/api/*`、`/ai/*` 一律直连不缓存。图标源自 `public/favicon.svg`,改版时需同步重生成 `public/pwa-*.png` 与 `apple-touch-icon.png`。
|
||||
|
||||
## 规范
|
||||
|
||||
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#F5F4ED" />
|
||||
<title>OCI Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+5649
-7
File diff suppressed because it is too large
Load Diff
+29
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "oci-portal-dash",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.8.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -9,13 +9,36 @@
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint . --fix",
|
||||
"test": "vitest run",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.1",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
"@novnc/novnc": "^1.7.0",
|
||||
"@prettier/plugin-xml": "^3.4.2",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@vue-office/docx": "^1.6.3",
|
||||
"@vue-office/excel": "^1.7.14",
|
||||
"@vue-office/pdf": "^2.0.10",
|
||||
"@vue-office/pptx": "^1.0.1",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"codemirror": "^6.0.2",
|
||||
"dompurify": "^3.4.12",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"marked": "^18.0.6",
|
||||
"marked-highlight": "^2.2.4",
|
||||
"naive-ui": "^2.41.0",
|
||||
"pinia": "^2.3.0",
|
||||
"vue": "^3.5.13",
|
||||
@@ -23,6 +46,7 @@
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/catppuccin": "^1.2.17",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/node": "^22.10.7",
|
||||
@@ -32,10 +56,13 @@
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-vue": "^9.32.0",
|
||||
"prettier": "^3.4.2",
|
||||
"happy-dom": "^20.11.0",
|
||||
"prettier": "^3.9.5",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 90 >> stream
|
||||
BT /F1 24 Tf 72 760 Td (OCI Portal PDF sample) Tj ET
|
||||
BT /F1 12 Tf 72 730 Td (rendered via vue-office/pdf) Tj ET
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
||||
trailer << /Root 1 0 R /Size 6 >>
|
||||
%%EOF
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,55 @@
|
||||
// 从 @iconify-json/catppuccin(Catppuccin VSCode Icons)提取对象存储用到的文件类型图标,
|
||||
// 生成 src/components/objectstorage/fileIcons.ts 静态模块,避免把 659 个图标全量打进 bundle。
|
||||
// 再生成:node scripts/gen-file-icons.mjs
|
||||
import { createRequire } from 'node:module'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { icons } = require('@iconify-json/catppuccin/icons.json')
|
||||
|
||||
// 图标名 → 该集合内的 icon key(catppuccin 集为 16x16)
|
||||
const PICK = [
|
||||
'folder',
|
||||
'file',
|
||||
'image',
|
||||
'json',
|
||||
'yaml',
|
||||
'toml',
|
||||
'properties',
|
||||
'python',
|
||||
'javascript',
|
||||
'typescript',
|
||||
'markdown',
|
||||
'database',
|
||||
'bash',
|
||||
'go',
|
||||
'zip',
|
||||
'pdf',
|
||||
'video',
|
||||
'audio',
|
||||
'html',
|
||||
'css',
|
||||
'text',
|
||||
'log',
|
||||
'csv',
|
||||
'xml',
|
||||
'binary',
|
||||
'ms-word',
|
||||
'ms-excel',
|
||||
'ms-powerpoint'
|
||||
]
|
||||
|
||||
const entries = PICK.map((name) => {
|
||||
const icon = icons[name]
|
||||
if (!icon) throw new Error(`icon not found: ${name}`)
|
||||
return ` ${JSON.stringify(name)}:\n '${icon.body.replaceAll("'", "\\'")}',`
|
||||
}).join('\n')
|
||||
|
||||
const out = `// 由 scripts/gen-file-icons.mjs 从 @iconify-json/catppuccin 提取,勿手改;
|
||||
// 来源:Catppuccin VSCode Icons(MIT),16x16 viewBox。
|
||||
export const FILE_ICON_BODIES: Record<string, string> = {
|
||||
${entries}
|
||||
}
|
||||
`
|
||||
writeFileSync(new URL('../src/components/objectstorage/fileIcons.ts', import.meta.url), out)
|
||||
console.log(`written ${PICK.length} icons`)
|
||||
+18
-1
@@ -1,6 +1,7 @@
|
||||
import { request } from './request'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import type {
|
||||
AiCallLog,
|
||||
AiCatalogModel,
|
||||
AiChannel,
|
||||
AiContentLog,
|
||||
AiKey,
|
||||
@@ -48,6 +49,8 @@ export function updateAiKeyContentLog(id: number, hours: number): Promise<AiKey>
|
||||
// ---- 渠道(号池)----
|
||||
|
||||
export function listAiChannels(): Promise<AiChannel[]> {
|
||||
// mock 缺此分支时请求会打到真后端,mock token 401 导致演示模式被登出
|
||||
if (mockOn) return mocked([])
|
||||
return request<{ items: AiChannel[] }>('/ai-channels').then((r) => r.items)
|
||||
}
|
||||
|
||||
@@ -83,12 +86,26 @@ export function syncAiChannelModels(id: number): Promise<AiModelCacheItem[]> {
|
||||
}).then((r) => r.items)
|
||||
}
|
||||
|
||||
export function listAiChannelModels(id: number): Promise<AiModelCacheItem[]> {
|
||||
return request<{ items: AiModelCacheItem[] }>(`/ai-channels/${id}/models`).then((r) => r.items)
|
||||
}
|
||||
|
||||
/** 单模型 max_tokens=16 试调;通过即设为该渠道探测验证模型并按需置渠道可用 */
|
||||
export function testAiChannelModel(id: number, model: string): Promise<AiChannel> {
|
||||
return request(`/ai-channels/${id}/test-model`, { method: 'POST', body: { model } })
|
||||
}
|
||||
|
||||
// ---- 聚合模型与调用日志 ----
|
||||
|
||||
export function listAiModels(): Promise<AiModel[]> {
|
||||
return request<{ items: AiModel[] }>('/ai-models').then((r) => r.items)
|
||||
}
|
||||
|
||||
/** 聚合模型目录(含能力,启用渠道去重;不受「过滤弃用」影响),黑名单添加弹窗用 */
|
||||
export function listAiModelCatalog(): Promise<AiCatalogModel[]> {
|
||||
return request<{ items: AiCatalogModel[] }>('/ai-model-catalog').then((r) => r.items)
|
||||
}
|
||||
|
||||
// ---- 网关全局设置 ----
|
||||
|
||||
export function getAiSettings(): Promise<AiSettings> {
|
||||
|
||||
@@ -17,9 +17,9 @@ export function getInstanceTraffic(
|
||||
export interface CostQuery {
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
granularity?: 'DAILY' | 'MONTHLY'
|
||||
granularity?: 'HOURLY' | 'DAILY' | 'MONTHLY'
|
||||
queryType?: 'COST' | 'USAGE'
|
||||
groupBy?: 'service' | 'skuName' | 'region' | 'compartmentName'
|
||||
groupBy?: 'service' | 'skuName' | 'region' | 'compartmentName' | 'service,skuName'
|
||||
}
|
||||
|
||||
export function getCosts(cfgId: number, query: CostQuery = {}): Promise<CostItem[]> {
|
||||
|
||||
+174
-24
@@ -1,3 +1,10 @@
|
||||
import type {
|
||||
AuthenticationResponseJSON,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
PublicKeyCredentialRequestOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
} from '@simplewebauthn/browser'
|
||||
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import type {
|
||||
CredentialsInfo,
|
||||
@@ -5,6 +12,9 @@ import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
OAuthSettings,
|
||||
PasskeyCeremonyOptions,
|
||||
PasskeyInfo,
|
||||
SessionItem,
|
||||
SessionRefresh,
|
||||
TotpSetup,
|
||||
TotpStatus,
|
||||
@@ -32,7 +42,36 @@ export function logout(): Promise<void> {
|
||||
/** 撤销全部会话:旧 token 全部失效,响应带操作者的新会话 */
|
||||
export function revokeSessions(): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/revoke-sessions', { method: 'POST' })
|
||||
return request('/auth/revoke-sessions', { method: 'POST', refreshesSession: true })
|
||||
}
|
||||
|
||||
/** mock 会话样例:相对当前时间偏移,保证「最近活跃」展示合理 */
|
||||
function mockSessions(): { items: SessionItem[] } {
|
||||
const ago = (min: number) => new Date(Date.now() - min * 60000).toISOString()
|
||||
const later = new Date(Date.now() + 23 * 3600000).toISOString()
|
||||
const mk = (id: number, method: string, clientIp: string, userAgent: string, seen: number, created: number, current = false): SessionItem => ({
|
||||
id, method, clientIp, userAgent,
|
||||
createdAt: ago(created), lastSeenAt: ago(seen), expiresAt: later, current,
|
||||
})
|
||||
return {
|
||||
items: [
|
||||
mk(1, 'password', '198.51.100.7', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/126.0 Safari/537.36', 0, 180, true),
|
||||
mk(2, 'passkey', '203.0.113.24', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 Version/17.5 Mobile/15E148 Safari/604.1', 185, 205),
|
||||
mk(3, 'wallet', '192.0.2.88', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/126.0 Safari/537.36 Edg/126.0', 780, 800),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 活跃会话列表:未撤销、未过期、版本为当前,最近活跃在前 */
|
||||
export function listSessions(): Promise<{ items: SessionItem[] }> {
|
||||
if (mockOn) return mocked(mockSessions(), 300)
|
||||
return request('/auth/sessions')
|
||||
}
|
||||
|
||||
/** 定点撤销一个其他会话;当前会话不可撤销(请走退出登录) */
|
||||
export function revokeSession(id: number): Promise<void> {
|
||||
if (mockOn) return mocked(undefined, 250)
|
||||
return request(`/auth/sessions/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ---- 登录凭据 ----
|
||||
@@ -45,13 +84,17 @@ export function getCredentials(): Promise<CredentialsInfo> {
|
||||
/** 修改用户名 / 密码;成功后旧 token 全部失效,响应带操作者的新会话 */
|
||||
export function updateCredentials(body: UpdateCredentialsRequest): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 400)
|
||||
return request('/auth/credentials', { method: 'PUT', body })
|
||||
return request('/auth/credentials', { method: 'PUT', body, refreshesSession: true })
|
||||
}
|
||||
|
||||
/** 保存密码登录禁用开关;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function updatePasswordLogin(disabled: boolean): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/password-login', { method: 'PUT', body: { disabled } })
|
||||
return request('/auth/password-login', {
|
||||
method: 'PUT',
|
||||
body: { disabled },
|
||||
refreshesSession: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 两步验证(TOTP) ----
|
||||
@@ -74,22 +117,29 @@ export function setupTotp(): Promise<TotpSetup> {
|
||||
/** 激活两步验证;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function activateTotp(code: string): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/totp/activate', { method: 'POST', body: { code } })
|
||||
return request('/auth/totp/activate', {
|
||||
method: 'POST',
|
||||
body: { code },
|
||||
refreshesSession: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */
|
||||
export function disableTotp(body: { password?: string; code?: string }): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/totp/disable', { method: 'POST', body })
|
||||
return request('/auth/totp/disable', { method: 'POST', body, refreshesSession: true })
|
||||
}
|
||||
|
||||
// ---- 外部身份(OAuth) ----
|
||||
|
||||
/** 可登录的 provider 列表(登录页公开接口;已禁用的不返回);
|
||||
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单 */
|
||||
* passwordLoginDisabled 为 true 且有可用 provider 时,登录页隐藏密码表单;
|
||||
* passkeyLogin / walletLogin 为 true 表示存在对应凭据,登录页显示入口 */
|
||||
export function getOauthProviders(): Promise<{
|
||||
providers: OauthProviderInfo[]
|
||||
passwordLoginDisabled: boolean
|
||||
passkeyLogin: boolean
|
||||
walletLogin: boolean
|
||||
}> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
@@ -98,6 +148,8 @@ export function getOauthProviders(): Promise<{
|
||||
{ provider: 'oidc', displayName: 'OIDC SSO' },
|
||||
],
|
||||
passwordLoginDisabled: false,
|
||||
passkeyLogin: true,
|
||||
walletLogin: true,
|
||||
})
|
||||
return request('/auth/oauth/providers')
|
||||
}
|
||||
@@ -124,14 +176,113 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
|
||||
/** 解绑外部身份;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function unbindIdentity(id: number): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request(`/auth/identities/${id}`, { method: 'DELETE' })
|
||||
return request(`/auth/identities/${id}`, { method: 'DELETE', refreshesSession: true })
|
||||
}
|
||||
|
||||
// ---- 通行密钥(Passkey) ----
|
||||
|
||||
const mockPasskeyRegisterOptions: PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON> = {
|
||||
sessionId: 'mock-session',
|
||||
options: {
|
||||
publicKey: {
|
||||
challenge: 'bW9jaw',
|
||||
rp: { id: 'localhost', name: 'OCI Portal' },
|
||||
user: { id: 'AQ', name: 'admin', displayName: 'admin' },
|
||||
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 发起通行密钥注册;sessionId 需原样带回 finish */
|
||||
export function beginPasskeyRegister(): Promise<
|
||||
PasskeyCeremonyOptions<PublicKeyCredentialCreationOptionsJSON>
|
||||
> {
|
||||
if (mockOn) return mocked(mockPasskeyRegisterOptions, 300)
|
||||
return request('/auth/passkey/register/begin', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** 完成通行密钥注册;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function finishPasskeyRegister(
|
||||
sessionId: string,
|
||||
name: string,
|
||||
credential: RegistrationResponseJSON,
|
||||
): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/passkey/register/finish', {
|
||||
method: 'POST',
|
||||
body: { sessionId, name, credential },
|
||||
refreshesSession: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function listPasskeys(): Promise<{ items: PasskeyInfo[] }> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
items: [
|
||||
{ id: 1, name: 'MacBook Touch ID', createdAt: '2026-07-20T10:00:00+08:00', lastUsedAt: null },
|
||||
],
|
||||
})
|
||||
return request('/auth/passkeys')
|
||||
}
|
||||
|
||||
/** 删除通行密钥;成功后旧 token 全部失效,响应带新会话 */
|
||||
export function removePasskey(id: number): Promise<SessionRefresh> {
|
||||
if (mockOn) return mocked({ token: 'mock-token-2', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request(`/auth/passkeys/${id}`, { method: 'DELETE', refreshesSession: true })
|
||||
}
|
||||
|
||||
/** 发起通行密钥登录(公开接口) */
|
||||
export function beginPasskeyLogin(): Promise<
|
||||
PasskeyCeremonyOptions<PublicKeyCredentialRequestOptionsJSON>
|
||||
> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
sessionId: 'mock-session',
|
||||
options: { publicKey: { challenge: 'bW9jaw', rpId: 'localhost' } },
|
||||
})
|
||||
return request('/auth/passkey/login/begin', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** 完成通行密钥登录;UV 通过后豁免 TOTP */
|
||||
export function finishPasskeyLogin(
|
||||
sessionId: string,
|
||||
credential: AuthenticationResponseJSON,
|
||||
): Promise<LoginResponse> {
|
||||
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/passkey/login/finish', {
|
||||
method: 'POST',
|
||||
body: { sessionId, credential },
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Web3 钱包(SIWE) ----
|
||||
|
||||
/** 发起钱包签名挑战;mode=bind 要求已登录,message 需原样 personal_sign */
|
||||
export function getWalletChallenge(
|
||||
address: string,
|
||||
mode: 'login' | 'bind',
|
||||
): Promise<{ nonce: string; message: string }> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
nonce: 'mock-nonce',
|
||||
message: `localhost wants you to sign in with your Ethereum account:\n${address}\n\n登录 OCI Portal 面板\n\nNonce: mock-nonce`,
|
||||
})
|
||||
return request('/auth/wallet/challenge', { method: 'POST', body: { address, mode } })
|
||||
}
|
||||
|
||||
/** 校验钱包签名:login 返回新会话;bind 返回换发的新 token(旧会话已失效) */
|
||||
export function verifyWallet(nonce: string, signature: string): Promise<LoginResponse> {
|
||||
if (mockOn) return mocked({ token: 'mock-token', expiresAt: '2099-01-01T00:00:00Z' }, 300)
|
||||
return request('/auth/wallet/verify', {
|
||||
method: 'POST',
|
||||
body: { nonce, signature },
|
||||
refreshesSession: true,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- OAuth provider 配置(设置页) ----
|
||||
|
||||
export function getOAuthSettings(): Promise<OAuthSettings> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
const mockOauth: OAuthSettings = {
|
||||
oidcIssuer: '',
|
||||
oidcClientId: '',
|
||||
oidcSecretSet: false,
|
||||
@@ -141,22 +292,21 @@ export function getOAuthSettings(): Promise<OAuthSettings> {
|
||||
githubSecretSet: false,
|
||||
githubDisplayName: '',
|
||||
githubDisabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function getOAuthSettings(): Promise<OAuthSettings> {
|
||||
if (mockOn) return mocked({ ...mockOauth })
|
||||
return request('/settings/oauth')
|
||||
}
|
||||
|
||||
/** 字段补丁部分更新:缺省字段沿用现值,并发编辑不同 provider 互不回滚 */
|
||||
export function updateOAuthSettings(body: UpdateOAuthRequest): Promise<OAuthSettings> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
oidcIssuer: body.oidcIssuer,
|
||||
oidcClientId: body.oidcClientId,
|
||||
oidcSecretSet: !!body.oidcClientSecret,
|
||||
oidcDisplayName: body.oidcDisplayName,
|
||||
oidcDisabled: body.oidcDisabled,
|
||||
githubClientId: body.githubClientId,
|
||||
githubSecretSet: !!body.githubClientSecret,
|
||||
githubDisplayName: body.githubDisplayName,
|
||||
githubDisabled: body.githubDisabled,
|
||||
})
|
||||
return request('/settings/oauth', { method: 'PUT', body })
|
||||
if (mockOn) {
|
||||
const { oidcClientSecret, githubClientSecret, ...rest } = body
|
||||
Object.assign(mockOauth, Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined)))
|
||||
if (oidcClientSecret !== undefined) mockOauth.oidcSecretSet = oidcClientSecret !== ''
|
||||
if (githubClientSecret !== undefined) mockOauth.githubSecretSet = githubClientSecret !== ''
|
||||
return mocked({ ...mockOauth })
|
||||
}
|
||||
return request('/settings/oauth', { method: 'PATCH', body })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { mockInvoiceLines, mockInvoices, mockPaymentMethods } from './mock'
|
||||
import { mockOn, mocked, rawFetch, request } from './request'
|
||||
import type { Invoice, InvoiceLine, PaymentMethod } from '@/types/api'
|
||||
|
||||
// 账单(OSP Gateway):发票数据挂租户主区域,面板后端代理转发。
|
||||
|
||||
/** 列出发票;year 传自然年按开票时间过滤(前端按年懒加载),缺省全量 */
|
||||
export function listInvoices(id: number, year?: number): Promise<Invoice[]> {
|
||||
if (mockOn)
|
||||
return mocked(
|
||||
year ? mockInvoices.filter((i) => (i.timeInvoice ?? '').startsWith(String(year))) : mockInvoices,
|
||||
)
|
||||
return request(`/oci-configs/${id}/invoices`, { query: { year } })
|
||||
}
|
||||
|
||||
export function listInvoiceLines(id: number, internalId: string): Promise<InvoiceLine[]> {
|
||||
if (mockOn) return mocked(mockInvoiceLines)
|
||||
return request(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/lines`)
|
||||
}
|
||||
|
||||
/** 按订阅默认付款方式支付发票;email 接收 OSP 付款回执 */
|
||||
export function payInvoice(id: number, internalId: string, email: string): Promise<{ status: string }> {
|
||||
if (mockOn) return mocked({ status: 'submitted' })
|
||||
return request(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/pay`, {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
})
|
||||
}
|
||||
|
||||
export function listPaymentMethods(id: number): Promise<PaymentMethod[]> {
|
||||
if (mockOn) return mocked(mockPaymentMethods)
|
||||
return request(`/oci-configs/${id}/payment-methods`)
|
||||
}
|
||||
|
||||
/** 拉取发票 PDF 原文(接口带 JWT 不能裸链接);预览与下载共用 */
|
||||
export function fetchInvoicePdf(id: number, internalId: string): Promise<Blob> {
|
||||
if (mockOn) return fetch('/mock-samples/sample.pdf').then((r) => r.blob())
|
||||
return rawFetch(`/oci-configs/${id}/invoices/${encodeURIComponent(internalId)}/pdf`).then((r) =>
|
||||
r.blob(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 触发浏览器保存(blob 已在手时用,如预览框内下载) */
|
||||
export function saveInvoicePdf(blob: Blob, name: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
+75
-4
@@ -8,7 +8,9 @@ import {
|
||||
mockVolAttachments,
|
||||
mockVolumes,
|
||||
} from './mock'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { ApiError, mockOn, mocked, request } from './request'
|
||||
import type {
|
||||
AttachVnicRequest,
|
||||
BootVolumeAttachment,
|
||||
@@ -118,13 +120,41 @@ export function updateInstance(
|
||||
})
|
||||
}
|
||||
|
||||
export function terminateInstance(
|
||||
/** 终止请求飞行中的实例(instance OCID):模块级响应式集合,列表页、
|
||||
* 详情页与组件重挂载共用一把锁,防「关闭重开对话框后用相反的
|
||||
* preserveBootVolume 并发提交」;跨浏览器页签由服务端同键 guard 兜底 */
|
||||
export const terminatingInstances = reactive(new Set<string>())
|
||||
|
||||
/** Instance OCID 唯一标识物理实例;cfgId 不得拆成两把终止锁 */
|
||||
export function terminatingKey(_cfgId: number, instanceId: string): string {
|
||||
return instanceId
|
||||
}
|
||||
|
||||
export async function terminateInstance(
|
||||
cfgId: number,
|
||||
instanceId: string,
|
||||
preserveBootVolume: boolean,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
const key = terminatingKey(cfgId, instanceId)
|
||||
if (terminatingInstances.has(key)) {
|
||||
throw new ApiError(409, '该实例的终止请求正在处理中')
|
||||
}
|
||||
terminatingInstances.add(key)
|
||||
try {
|
||||
if (mockOn) return await mocked(undefined, 600)
|
||||
return await requestTerminate(cfgId, instanceId, preserveBootVolume, region)
|
||||
} finally {
|
||||
terminatingInstances.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function requestTerminate(
|
||||
cfgId: number,
|
||||
instanceId: string,
|
||||
preserveBootVolume: boolean,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) return mocked(undefined, 600)
|
||||
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}`, {
|
||||
method: 'DELETE',
|
||||
query: { preserveBootVolume, region },
|
||||
@@ -187,7 +217,35 @@ export function removeIpv6(
|
||||
|
||||
// ---- VNIC 管理 ----
|
||||
export function listVnics(cfgId: number, instanceId: string, region?: string): Promise<Vnic[]> {
|
||||
if (mockOn) return mocked([])
|
||||
if (mockOn)
|
||||
return mocked([
|
||||
{
|
||||
attachmentId: 'ocid1.vnicattachment..a1',
|
||||
vnicId: 'ocid1.vnic..v1',
|
||||
displayName: instanceId.slice(-12),
|
||||
isPrimary: true,
|
||||
privateIp: '10.0.0.11',
|
||||
publicIp: '141.147.60.10',
|
||||
ipv6Addresses: ['2603:c020:400d:aa01::1'],
|
||||
subnetId: 'ocid1.subnet..pub',
|
||||
subnetName: 'public-subnet',
|
||||
lifecycleState: 'ATTACHED',
|
||||
timeCreated: '2026-05-20T14:32:00Z',
|
||||
},
|
||||
{
|
||||
attachmentId: 'ocid1.vnicattachment..a2',
|
||||
vnicId: 'ocid1.vnic..v2',
|
||||
displayName: 'vnic-secondary',
|
||||
isPrimary: false,
|
||||
privateIp: '10.0.0.12',
|
||||
publicIp: '',
|
||||
ipv6Addresses: [],
|
||||
subnetId: 'ocid1.subnet..pub',
|
||||
subnetName: 'public-subnet',
|
||||
lifecycleState: 'ATTACHED',
|
||||
timeCreated: '2026-07-20T08:00:00Z',
|
||||
},
|
||||
])
|
||||
return request(`/oci-configs/${cfgId}/instances/${encodeURIComponent(instanceId)}/vnics`, {
|
||||
query: { region },
|
||||
})
|
||||
@@ -213,6 +271,19 @@ export function detachVnic(cfgId: number, attachmentId: string, region?: string)
|
||||
})
|
||||
}
|
||||
|
||||
/** 更换指定 VNIC 的临时公网 IP,返回新地址;绑定保留 IP 的网卡会被拒绝 */
|
||||
export function changeVnicPublicIp(
|
||||
cfgId: number,
|
||||
vnicId: string,
|
||||
region?: string,
|
||||
): Promise<{ publicIp: string }> {
|
||||
if (mockOn) return mocked({ publicIp: '130.61.99.7' }, 800)
|
||||
return request(`/oci-configs/${cfgId}/vnics/${encodeURIComponent(vnicId)}/change-public-ip`, {
|
||||
method: 'POST',
|
||||
body: { region: region ?? '' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 为指定 VNIC 添加 IPv6;address 留空自动分配。删除复用实例级 removeIpv6(按地址遍历全部网卡) */
|
||||
export function addVnicIpv6(
|
||||
cfgId: number,
|
||||
|
||||
+245
@@ -1,4 +1,8 @@
|
||||
import type {
|
||||
Bucket,
|
||||
ObjectSummary,
|
||||
Par,
|
||||
ReservedIp,
|
||||
SecuritySetting,
|
||||
AuditEvent,
|
||||
BootVolume,
|
||||
@@ -14,6 +18,8 @@ import type {
|
||||
ImageInfo,
|
||||
Instance,
|
||||
InstanceTraffic,
|
||||
Invoice,
|
||||
InvoiceLine,
|
||||
Ipv6Report,
|
||||
LimitItem,
|
||||
LogEvent,
|
||||
@@ -23,6 +29,7 @@ import type {
|
||||
OciConfig,
|
||||
Overview,
|
||||
PasswordPolicy,
|
||||
PaymentMethod,
|
||||
RegionInfo,
|
||||
RegionSubscription,
|
||||
SecurityList,
|
||||
@@ -509,6 +516,7 @@ export const mockRegionSubs: RegionSubscription[] = [
|
||||
]
|
||||
|
||||
export const mockLimits: LimitItem[] = [
|
||||
// A1:三个 AD 均有配额 + 区域总额(镜像真实免费租户形态)
|
||||
{
|
||||
name: 'standard-a1-core-count',
|
||||
scopeType: 'AD',
|
||||
@@ -517,6 +525,19 @@ export const mockLimits: LimitItem[] = [
|
||||
used: 4,
|
||||
available: 0,
|
||||
},
|
||||
{
|
||||
name: 'standard-a1-core-count',
|
||||
scopeType: 'AD',
|
||||
availabilityDomain: 'RssO:EU-FRANKFURT-1-AD-2',
|
||||
value: 4,
|
||||
},
|
||||
{
|
||||
name: 'standard-a1-core-count',
|
||||
scopeType: 'AD',
|
||||
availabilityDomain: 'RssO:EU-FRANKFURT-1-AD-3',
|
||||
value: 4,
|
||||
},
|
||||
{ name: 'standard-a1-core-regional-count', scopeType: 'REGION', value: 4 },
|
||||
{
|
||||
name: 'standard-a1-memory-count',
|
||||
scopeType: 'AD',
|
||||
@@ -525,14 +546,27 @@ export const mockLimits: LimitItem[] = [
|
||||
used: 24,
|
||||
available: 0,
|
||||
},
|
||||
// Micro:仅 AD-2 有配额(AD 受限 shape,驱动创建表单自动落位)
|
||||
{
|
||||
name: 'standard-e2-micro-core-count',
|
||||
scopeType: 'AD',
|
||||
availabilityDomain: 'RssO:EU-FRANKFURT-1-AD-1',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: 'standard-e2-micro-core-count',
|
||||
scopeType: 'AD',
|
||||
availabilityDomain: 'RssO:EU-FRANKFURT-1-AD-2',
|
||||
value: 2,
|
||||
used: 1,
|
||||
available: 1,
|
||||
},
|
||||
{
|
||||
name: 'standard-e2-micro-core-count',
|
||||
scopeType: 'AD',
|
||||
availabilityDomain: 'RssO:EU-FRANKFURT-1-AD-3',
|
||||
value: 0,
|
||||
},
|
||||
{
|
||||
name: 'standard-e4-core-count',
|
||||
scopeType: 'AD',
|
||||
@@ -542,6 +576,15 @@ export const mockLimits: LimitItem[] = [
|
||||
available: 4,
|
||||
},
|
||||
{ name: 'vcn-count', scopeType: 'REGION', value: 50, used: 2, available: 48 },
|
||||
// 对象存储:额度条数据源(免费号 20GB 上限)
|
||||
{
|
||||
name: 'storage-bytes',
|
||||
scopeType: 'REGION',
|
||||
value: 21474836480,
|
||||
used: 2003218,
|
||||
available: 21472833262,
|
||||
},
|
||||
{ name: 'bucket-count', scopeType: 'REGION', value: 10000, used: 5, available: 9995 },
|
||||
{ name: 'volume-backup-count', scopeType: 'REGION', value: 100, used: 6, available: 94 },
|
||||
]
|
||||
|
||||
@@ -995,6 +1038,128 @@ export const mockTraffic: InstanceTraffic = {
|
||||
],
|
||||
}
|
||||
|
||||
export const mockBuckets: Bucket[] = [
|
||||
{
|
||||
name: 'backups',
|
||||
namespace: 'mocknamespace',
|
||||
visibility: 'NoPublicAccess',
|
||||
storageTier: 'Standard',
|
||||
versioningOn: true,
|
||||
approximateCount: 128,
|
||||
approximateSize: 4.2 * 1024 ** 3,
|
||||
timeCreated: '2025-11-03T14:20:00Z',
|
||||
},
|
||||
{
|
||||
name: 'static-assets',
|
||||
namespace: 'mocknamespace',
|
||||
visibility: 'ObjectRead',
|
||||
storageTier: 'Standard',
|
||||
versioningOn: false,
|
||||
approximateCount: 56,
|
||||
approximateSize: 820 * 1024 ** 2,
|
||||
timeCreated: '2025-12-18T09:41:00Z',
|
||||
},
|
||||
// 超长桶名样本:走查删除确认标题 / 桶设置弹窗的截断表现
|
||||
{
|
||||
name: 'oci-logs._flowlogs.ocid1.tenancy.oc1..aaaaaaaa57ydufmess6ni2vsjjkfmvbi',
|
||||
namespace: 'mocknamespace',
|
||||
visibility: 'NoPublicAccess',
|
||||
storageTier: 'Standard',
|
||||
versioningOn: false,
|
||||
approximateCount: 3021,
|
||||
approximateSize: 1.1 * 1024 ** 3,
|
||||
timeCreated: '2026-03-22T06:10:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockObjects: ObjectSummary[] = [
|
||||
{ name: 'db/daily/app-0718.sql.gz', size: 3.2 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-18T03:30:00Z' },
|
||||
{ name: 'db/app.sql.gz', size: 3.4 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-19T03:30:00Z' },
|
||||
{ name: 'db/wal-0007.tar', size: 1.7 * 1024 ** 3, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-18T03:30:00Z' },
|
||||
{ name: 'db/2025-snapshot.tar', size: 890 * 1024 ** 2, storageTier: 'Archive', archivalState: 'Archived', timeModified: '2026-01-02T22:05:00Z' },
|
||||
{ name: 'config.yaml', size: 2100, storageTier: 'Standard', archivalState: '', timeModified: '2026-06-30T11:02:00Z' },
|
||||
{ name: 'README.md', size: 1800, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-15T08:20:00Z' },
|
||||
{ name: 'logo.svg', size: 2400, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-10T15:44:00Z' },
|
||||
{ name: '季度报告.docx', size: 384 * 1024, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-08T09:12:00Z' },
|
||||
{ name: '预算表.xlsx', size: 92 * 1024, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-12T14:03:00Z' },
|
||||
{ name: '发布计划.pptx', size: 1.6 * 1024 ** 2, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-17T10:30:00Z' },
|
||||
{ name: '架构说明.pdf', size: 512, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-19T16:40:00Z' },
|
||||
{ name: 'gateway.json', size: 84, storageTier: 'Standard', archivalState: '', timeModified: '2026-07-20T09:10:00Z' },
|
||||
]
|
||||
|
||||
/** mock 对象文本内容(面板中转预览/编辑用);office/pdf 经 /mock-samples/ 静态样例提供 */
|
||||
export const mockObjectContents: Record<string, string> = {
|
||||
'gateway.json': `{"name":"gw","port":8080,"tls":true,"upstreams":["10.0.0.2","10.0.0.3"],"limits":{"burst":64}}`,
|
||||
'config.yaml': `server:
|
||||
port: 8080
|
||||
host: 0.0.0.0
|
||||
|
||||
database:
|
||||
driver: sqlite
|
||||
path: /data/app.db
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
`,
|
||||
'README.md': `# 对象存储示例
|
||||
|
||||
这是一段 **markdown** 预览示例,支持围栏代码块高亮:
|
||||
|
||||
\`\`\`python
|
||||
def hello(name: str) -> str:
|
||||
return f"hello {name}"
|
||||
\`\`\`
|
||||
|
||||
\`\`\`bash
|
||||
pip install -r requirements.txt
|
||||
python main.py --port 8080
|
||||
\`\`\`
|
||||
|
||||
- 列表项一
|
||||
- 列表项二
|
||||
|
||||
> 引用块:预览与编辑经面板中转,不再签发 PAR。
|
||||
`,
|
||||
'logo.svg': `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="50" fill="#C96442" />
|
||||
<text x="60" y="68" text-anchor="middle" font-size="28" fill="#fff">OCI</text>
|
||||
</svg>
|
||||
`,
|
||||
}
|
||||
|
||||
export const mockPars: Par[] = [
|
||||
{
|
||||
id: 'par-mock-1',
|
||||
name: 'par-db-app.sql.gz',
|
||||
objectName: 'db/app.sql.gz',
|
||||
accessType: 'ObjectRead',
|
||||
timeExpires: '2026-07-21T03:30:00Z',
|
||||
timeCreated: '2026-07-20T03:30:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockReservedIps: ReservedIp[] = [
|
||||
{
|
||||
id: 'ocid1.publicip.oc1..r1',
|
||||
displayName: 'tokyo-keep-1',
|
||||
ipAddress: '155.248.200.10',
|
||||
lifecycleState: 'ASSIGNED',
|
||||
assignedInstanceId: 'ocid1.instance.oc1..inst1',
|
||||
assignedInstanceName: 'tokyo-web-1',
|
||||
timeCreated: '2026-05-12T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ocid1.publicip.oc1..r2',
|
||||
displayName: 'spare-ip',
|
||||
ipAddress: '152.69.198.88',
|
||||
lifecycleState: 'AVAILABLE',
|
||||
assignedInstanceId: '',
|
||||
assignedInstanceName: '',
|
||||
timeCreated: '2026-07-01T02:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockVcns: Record<number, Vcn[]> = {
|
||||
1: [
|
||||
{
|
||||
@@ -1269,6 +1434,14 @@ export const mockCompartments: Record<number, Compartment[]> = {
|
||||
lifecycleState: 'ACTIVE',
|
||||
timeCreated: '2026-06-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ocid1.compartment.oc1..devsandbox',
|
||||
name: 'dev-sandbox',
|
||||
description: 'dev 下的嵌套子区间',
|
||||
parentId: 'ocid1.compartment.oc1..dev',
|
||||
lifecycleState: 'ACTIVE',
|
||||
timeCreated: '2026-06-10T00:00:00Z',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1374,6 +1547,12 @@ export const mockShapes: ComputeShape[] = [
|
||||
memoryMinGBs: 1,
|
||||
memoryMaxGBs: 512,
|
||||
processorDescription: '3.0 GHz Ampere® Altra™',
|
||||
quotaNames: [
|
||||
'standard-a1-memory-count',
|
||||
'standard-a1-memory-regional-count',
|
||||
'standard-a1-core-count',
|
||||
'standard-a1-core-regional-count',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'VM.Standard.E2.1.Micro',
|
||||
@@ -1382,6 +1561,7 @@ export const mockShapes: ComputeShape[] = [
|
||||
ocpus: 1,
|
||||
memoryInGBs: 1,
|
||||
processorDescription: '2.0 GHz AMD EPYC™ 7551',
|
||||
quotaNames: ['standard-e2-micro-core-count'],
|
||||
},
|
||||
{
|
||||
name: 'VM.Standard.E4.Flex',
|
||||
@@ -1392,6 +1572,7 @@ export const mockShapes: ComputeShape[] = [
|
||||
memoryMinGBs: 1,
|
||||
memoryMaxGBs: 1024,
|
||||
processorDescription: '2.55 GHz AMD EPYC™ 7J13',
|
||||
quotaNames: ['standard-e4-core-count', 'standard-e4-memory-count'],
|
||||
},
|
||||
{
|
||||
name: 'VM.Standard.E5.Flex',
|
||||
@@ -1715,3 +1896,67 @@ export const mockLogWebhooks = new Map<number, LogWebhookInfo>()
|
||||
|
||||
// OCI 侧回传链路:mock 态记录「已一键创建」的租户
|
||||
export const mockLogRelays = new Map<number, boolean>()
|
||||
|
||||
// ---- 账单:发票 / 明细 / 付款方式 ----
|
||||
export const mockInvoices: Invoice[] = [
|
||||
{
|
||||
id: 'inv-202607', internalId: '61001', number: '100482756', type: 'USAGE', status: 'OPEN',
|
||||
isPaid: false, isPayable: true, isPaymentFailed: false, amount: 603.53, amountDue: 603.53,
|
||||
currency: 'USD', timeInvoice: '2026-07-01T00:00:00Z', timeDue: '2026-07-31T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202606', internalId: '61002', number: '100471203', type: 'USAGE', status: 'PAST_DUE',
|
||||
isPaid: false, isPayable: true, isPaymentFailed: true, amount: 538.17, amountDue: 538.17,
|
||||
currency: 'USD', timeInvoice: '2026-06-01T00:00:00Z', timeDue: '2026-06-30T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202605', internalId: '61003', number: '100458820', type: 'USAGE', status: 'PAYMENT_SUBMITTED',
|
||||
isPaid: false, isPayable: false, isPaymentFailed: false, amount: 512.4, amountDue: 512.4,
|
||||
currency: 'USD', timeInvoice: '2026-05-01T00:00:00Z', timeDue: '2026-05-31T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202604', internalId: '61004', number: '100446351', type: 'USAGE', status: 'CLOSED',
|
||||
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 529.02, amountDue: 0,
|
||||
currency: 'USD', timeInvoice: '2026-04-01T00:00:00Z', timeDue: '2026-04-30T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202603', internalId: '61005', number: '100433978', type: 'USAGE', status: 'CLOSED',
|
||||
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 498.75, amountDue: 0,
|
||||
currency: 'USD', timeInvoice: '2026-03-01T00:00:00Z', timeDue: '2026-03-31T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202602', internalId: '61006', number: '100421510', type: 'USAGE', status: 'CLOSED',
|
||||
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 533.19, amountDue: 0,
|
||||
currency: 'USD', timeInvoice: '2026-02-01T00:00:00Z', timeDue: '2026-02-28T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202512', internalId: '61007', number: '100409112', type: 'USAGE', status: 'CLOSED',
|
||||
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 476.4, amountDue: 0,
|
||||
currency: 'USD', timeInvoice: '2025-12-01T00:00:00Z', timeDue: '2025-12-31T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'inv-202511', internalId: '61008', number: '100396805', type: 'USAGE', status: 'CLOSED',
|
||||
isPaid: true, isPayable: false, isPaymentFailed: false, amount: 441.87, amountDue: 0,
|
||||
currency: 'USD', timeInvoice: '2025-11-01T00:00:00Z', timeDue: '2025-11-30T00:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
export const mockInvoiceLines: InvoiceLine[] = [
|
||||
{ product: 'Compute - Standard.E4.Flex - OCPU', orderNo: '89S1024', quantity: 744, unitPrice: 0.025, total: 18.6, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
{ product: 'Compute - Standard.E4.Flex - Memory', orderNo: '89S1024', quantity: 11904, unitPrice: 0.0015, total: 17.86, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
{ product: 'Block Volume - Performance Units', orderNo: '89S1024', quantity: 1488, unitPrice: 0.0017, total: 2.53, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
{ product: 'Object Storage - Standard', orderNo: '89S1024', quantity: 512, unitPrice: 0.0255, total: 13.06, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
{ product: 'Outbound Data Transfer', orderNo: '89S1024', quantity: 2048, unitPrice: 0.0085, total: 17.41, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
{ product: 'Generative AI - Large Chat Tokens', orderNo: '89S1024', quantity: 3560.4, unitPrice: 0.15, total: 534.07, currency: 'USD', timeStart: '2026-07-01T00:00:00Z', timeEnd: '2026-07-31T00:00:00Z' },
|
||||
]
|
||||
|
||||
export const mockPaymentMethods: PaymentMethod[] = [
|
||||
{
|
||||
method: 'CREDIT_CARD', cardType: 'VISA', lastDigits: '4242',
|
||||
nameOnCard: 'LI MING', timeExpiration: '2028-08-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
method: 'PAYPAL', email: 'billing@example.com', payerName: 'Li Ming',
|
||||
billingAgreement: 'B-7KX3H2M9L4T9F2',
|
||||
},
|
||||
]
|
||||
|
||||
+20
-1
@@ -1,6 +1,6 @@
|
||||
import { mockIpv6Report, mockSecurityLists, mockSubnets, mockVcns } from './mock'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import type { CreateVcnRequest, Ipv6Report, SecurityList, Subnet, Vcn } from '@/types/api'
|
||||
import type { CreateVcnRequest, Ipv6Report, SecurityList, SecurityRule, Subnet, Vcn } from '@/types/api'
|
||||
|
||||
export function listVcns(cfgId: number, region?: string, compartmentId?: string): Promise<Vcn[]> {
|
||||
if (mockOn) return mocked(compartmentId ? [] : (mockVcns[cfgId] ?? []))
|
||||
@@ -64,3 +64,22 @@ export function listSecurityLists(
|
||||
if (mockOn) return mocked(mockSecurityLists.filter((s) => s.vcnId === vcnId || vcnId === ''))
|
||||
return request(`/oci-configs/${cfgId}/security-lists`, { query: { vcnId, region } })
|
||||
}
|
||||
|
||||
/** 更新安全列表;规则字段一经提供即整体替换,region 随请求体传递 */
|
||||
export function updateSecurityList(
|
||||
cfgId: number,
|
||||
securityListId: string,
|
||||
body: { region?: string; displayName?: string; ingressRules?: SecurityRule[]; egressRules?: SecurityRule[] },
|
||||
): Promise<SecurityList> {
|
||||
if (mockOn) {
|
||||
const found = mockSecurityLists.find((s) => s.id === securityListId)
|
||||
if (!found) return Promise.reject(new Error('安全列表不存在'))
|
||||
if (body.ingressRules) found.ingressRules = body.ingressRules
|
||||
if (body.egressRules) found.egressRules = body.egressRules
|
||||
return mocked({ ...found }, 500)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/security-lists/${encodeURIComponent(securityListId)}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { mockBuckets, mockObjectContents, mockObjects, mockPars } from './mock'
|
||||
import { mockOn, mocked, rawFetch, request } from './request'
|
||||
import type { Bucket, ListObjectsResult, ObjectDetail, Par, ParPage } from '@/types/api'
|
||||
|
||||
export function listBuckets(
|
||||
cfgId: number,
|
||||
region?: string,
|
||||
compartmentId?: string,
|
||||
): Promise<Bucket[]> {
|
||||
if (mockOn) return mocked([...mockBuckets])
|
||||
return request(`/oci-configs/${cfgId}/buckets`, { query: { region, compartmentId } })
|
||||
}
|
||||
|
||||
export function createBucket(
|
||||
cfgId: number,
|
||||
body: {
|
||||
region?: string
|
||||
compartmentId?: string
|
||||
name: string
|
||||
publicRead?: boolean
|
||||
storageTier?: string
|
||||
versioningOn?: boolean
|
||||
},
|
||||
): Promise<Bucket> {
|
||||
if (mockOn) {
|
||||
const b: Bucket = {
|
||||
name: body.name,
|
||||
namespace: 'mocknamespace',
|
||||
visibility: body.publicRead ? 'ObjectRead' : 'NoPublicAccess',
|
||||
storageTier: body.storageTier || 'Standard',
|
||||
versioningOn: !!body.versioningOn,
|
||||
approximateCount: 0,
|
||||
approximateSize: 0,
|
||||
timeCreated: new Date().toISOString(),
|
||||
}
|
||||
mockBuckets.push(b)
|
||||
return mocked(b, 600)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateBucket(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
body: { region?: string; publicRead?: boolean; versioningOn?: boolean },
|
||||
): Promise<Bucket> {
|
||||
if (mockOn) {
|
||||
const found = mockBuckets.find((b) => b.name === bucket)
|
||||
if (!found) return Promise.reject(new Error('桶不存在'))
|
||||
if (body.publicRead !== undefined)
|
||||
found.visibility = body.publicRead ? 'ObjectRead' : 'NoPublicAccess'
|
||||
if (body.versioningOn !== undefined) found.versioningOn = body.versioningOn
|
||||
return mocked({ ...found }, 400)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/** queued=true 表示非空桶已转后台清空(对象全部版本+PAR)后删除 */
|
||||
export function deleteBucket(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
region?: string,
|
||||
): Promise<{ queued: boolean }> {
|
||||
if (mockOn) {
|
||||
const idx = mockBuckets.findIndex((b) => b.name === bucket)
|
||||
if (idx >= 0) mockBuckets.splice(idx, 1)
|
||||
return mocked({ queued: false })
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}`, {
|
||||
method: 'DELETE',
|
||||
query: { region },
|
||||
})
|
||||
}
|
||||
|
||||
export function listObjects(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
opts: { region?: string; prefix?: string; startWith?: string; limit?: number } = {},
|
||||
): Promise<ListObjectsResult> {
|
||||
if (mockOn) {
|
||||
const prefix = opts.prefix ?? ''
|
||||
const inScope = mockObjects.filter((o) => o.name.startsWith(prefix))
|
||||
const prefixes = new Set<string>()
|
||||
const objects = inScope.filter((o) => {
|
||||
const rest = o.name.slice(prefix.length)
|
||||
const slash = rest.indexOf('/')
|
||||
if (slash < 0) return true
|
||||
prefixes.add(prefix + rest.slice(0, slash + 1))
|
||||
return false
|
||||
})
|
||||
return mocked({ objects, prefixes: [...prefixes], nextStartWith: '' })
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects`, {
|
||||
query: {
|
||||
region: opts.region,
|
||||
prefix: opts.prefix || undefined,
|
||||
startWith: opts.startWith || undefined,
|
||||
limit: opts.limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteObject(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
object: string,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
const idx = mockObjects.findIndex((o) => o.name === object)
|
||||
if (idx >= 0) mockObjects.splice(idx, 1)
|
||||
return mocked(undefined)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects`, {
|
||||
method: 'DELETE',
|
||||
query: { region, object },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameObject(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
source: string,
|
||||
newName: string,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
const found = mockObjects.find((o) => o.name === source)
|
||||
if (found) found.name = newName
|
||||
return mocked(undefined, 400)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/rename`, {
|
||||
method: 'POST',
|
||||
body: { region: region ?? '', source, newName },
|
||||
})
|
||||
}
|
||||
|
||||
export function restoreObject(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
object: string,
|
||||
region?: string,
|
||||
hours?: number,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
// mock 态模拟取回中,便于走查「取回中」标签与后台轮询
|
||||
const o = mockObjects.find((x) => x.name === object)
|
||||
if (o) o.archivalState = 'Restoring'
|
||||
return mocked(undefined, 500)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/restore`, {
|
||||
method: 'POST',
|
||||
body: { region: region ?? '', object, hours },
|
||||
})
|
||||
}
|
||||
|
||||
export function getObjectDetail(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
object: string,
|
||||
region?: string,
|
||||
): Promise<ObjectDetail> {
|
||||
if (mockOn) {
|
||||
const found = mockObjects.find((o) => o.name === object)
|
||||
if (!found) return Promise.reject(new Error('对象不存在'))
|
||||
return mocked({
|
||||
name: found.name,
|
||||
size: found.size,
|
||||
contentType: 'application/octet-stream',
|
||||
etag: 'a1b2c3d4-mock',
|
||||
contentMd5: 'tYx0mockkQ==',
|
||||
storageTier: found.storageTier,
|
||||
archivalState: found.archivalState,
|
||||
timeModified: found.timeModified,
|
||||
metadata: null,
|
||||
})
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/detail`, {
|
||||
query: { region, object },
|
||||
})
|
||||
}
|
||||
|
||||
export interface ObjectContent {
|
||||
blob: Blob
|
||||
contentType: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
/** 读取对象内容(面板中转,不签发 PAR);超过 20MB 后端返回 413 */
|
||||
export async function getObjectContent(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
object: string,
|
||||
region?: string,
|
||||
): Promise<ObjectContent> {
|
||||
if (mockOn) return mockGetContent(object)
|
||||
const resp = await rawFetch(
|
||||
`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/content`,
|
||||
{ query: { region, object } },
|
||||
)
|
||||
return {
|
||||
blob: await resp.blob(),
|
||||
contentType: resp.headers.get('Content-Type') ?? '',
|
||||
etag: resp.headers.get('ETag') ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_CT: Record<string, string> = { md: 'text/markdown', svg: 'image/svg+xml' }
|
||||
|
||||
/** mock:文本走内容表,office/pdf 走 /mock-samples/ 静态样例 */
|
||||
async function mockGetContent(object: string): Promise<ObjectContent> {
|
||||
const ext = object.split('.').pop()?.toLowerCase() ?? ''
|
||||
const text = mockObjectContents[object]
|
||||
if (text !== undefined) {
|
||||
await mocked(undefined, 300)
|
||||
const ct = MOCK_CT[ext] ?? 'text/plain'
|
||||
return { blob: new Blob([text], { type: ct }), contentType: ct, etag: 'mock-etag-1' }
|
||||
}
|
||||
// dev server 对不存在路径回退 index.html(200),按 Content-Type 识别缺样例
|
||||
const resp = await fetch(`/mock-samples/sample.${ext}`)
|
||||
if (!resp.ok || (resp.headers.get('Content-Type') ?? '').includes('text/html'))
|
||||
throw new Error('mock 环境无该对象的样例内容')
|
||||
return {
|
||||
blob: await resp.blob(),
|
||||
contentType: resp.headers.get('Content-Type') ?? '',
|
||||
etag: 'mock-etag-1',
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存对象内容(面板中转);ifMatch 冲突时抛 ApiError(status=412) */
|
||||
export async function putObjectContent(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
object: string,
|
||||
data: Blob | string,
|
||||
contentType?: string,
|
||||
ifMatch?: string,
|
||||
region?: string,
|
||||
): Promise<{ etag: string }> {
|
||||
if (mockOn) return mockPutContent(object, data)
|
||||
const headers: Record<string, string> = {}
|
||||
if (contentType) headers['Content-Type'] = contentType
|
||||
if (ifMatch) headers['If-Match'] = ifMatch
|
||||
const resp = await rawFetch(
|
||||
`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/objects/content`,
|
||||
{ method: 'PUT', query: { region, object }, headers, body: data },
|
||||
)
|
||||
return (await resp.json()) as { etag: string }
|
||||
}
|
||||
|
||||
async function mockPutContent(object: string, data: Blob | string): Promise<{ etag: string }> {
|
||||
const text = typeof data === 'string' ? data : ''
|
||||
mockObjectContents[object] = text
|
||||
const found = mockObjects.find((o) => o.name === object)
|
||||
if (found) {
|
||||
found.size = typeof data === 'string' ? new Blob([data]).size : data.size
|
||||
found.timeModified = new Date().toISOString()
|
||||
}
|
||||
return mocked({ etag: `mock-etag-${Date.now()}` }, 400)
|
||||
}
|
||||
|
||||
export function createPar(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
body: {
|
||||
region?: string
|
||||
name?: string
|
||||
objectName?: string
|
||||
accessType: string
|
||||
expiresHours?: number
|
||||
},
|
||||
): Promise<Par> {
|
||||
if (mockOn) {
|
||||
const par: Par = {
|
||||
id: `par-${Date.now()}`,
|
||||
name: body.name || `par-${body.objectName ?? bucket}`,
|
||||
objectName: body.objectName ?? '',
|
||||
accessType: body.accessType,
|
||||
fullUrl: `https://objectstorage.mock.oraclecloud.com/p/tOkEn/n/mocknamespace/b/${bucket}/o/${encodeURIComponent(body.objectName ?? '')}`,
|
||||
timeExpires: new Date(Date.now() + (body.expiresHours ?? 24) * 3600e3).toISOString(),
|
||||
timeCreated: new Date().toISOString(),
|
||||
}
|
||||
mockPars.push({ ...par, fullUrl: undefined })
|
||||
return mocked(par, 500)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function listPars(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
opts: { region?: string; page?: string; limit?: number } = {},
|
||||
): Promise<ParPage> {
|
||||
if (mockOn) return mocked({ items: [...mockPars], nextPage: '' })
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
|
||||
query: { region: opts.region, page: opts.page, limit: opts.limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function deletePar(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
parId: string,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
const idx = mockPars.findIndex((p) => p.id === parId)
|
||||
if (idx >= 0) mockPars.splice(idx, 1)
|
||||
return mocked(undefined)
|
||||
}
|
||||
// parId 含 "/" 等字符,路径参数无法匹配路由,经 query 传递
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
|
||||
method: 'DELETE',
|
||||
query: { region, parId },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAllPars(
|
||||
cfgId: number,
|
||||
bucket: string,
|
||||
region?: string,
|
||||
): Promise<{ deleted: number }> {
|
||||
if (mockOn) {
|
||||
const n = mockPars.length
|
||||
mockPars.splice(0)
|
||||
return mocked({ deleted: n }, 400)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/buckets/${encodeURIComponent(bucket)}/pars`, {
|
||||
method: 'DELETE',
|
||||
query: { region, all: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 经预签名 PAR 直传对象到 OCI(不经面板中转),onProgress 回调 0-100 */
|
||||
export function uploadViaPar(
|
||||
parUrl: string,
|
||||
file: File,
|
||||
onProgress: (percent: number) => void,
|
||||
): { promise: Promise<void>; abort: () => void } {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
xhr.onload = () =>
|
||||
xhr.status >= 200 && xhr.status < 300
|
||||
? resolve()
|
||||
: reject(new Error(`上传失败(HTTP ${xhr.status})`))
|
||||
xhr.onerror = () => reject(new Error('上传失败(网络错误)'))
|
||||
xhr.onabort = () => reject(new Error('已取消上传'))
|
||||
xhr.open('PUT', parUrl)
|
||||
xhr.send(file)
|
||||
})
|
||||
return { promise, abort: () => xhr.abort() }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
+175
-18
@@ -4,10 +4,13 @@ const BASE = '/api/v1'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
/** 后端透传的 OCI 错误码(如 OrganizationExclusionError),供调用方做能力适配 */
|
||||
ociCode?: string
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
constructor(status: number, message: string, ociCode?: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.ociCode = ociCode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +20,27 @@ interface RequestOptions {
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
/** 附加请求头 */
|
||||
headers?: Record<string, string>
|
||||
/** 成功响应会换发当前会话;并发 401 须等待其落入 Store 后再决定是否登出 */
|
||||
refreshesSession?: boolean
|
||||
}
|
||||
|
||||
interface RefreshWaiter {
|
||||
resolve: (waited: boolean) => void
|
||||
}
|
||||
|
||||
interface RefreshState {
|
||||
count: number
|
||||
waiters: RefreshWaiter[]
|
||||
}
|
||||
|
||||
interface ErrorDetails {
|
||||
message: string
|
||||
ociCode?: string
|
||||
rateLimited: boolean
|
||||
}
|
||||
|
||||
const sessionRefreshes = new Map<string, RefreshState>()
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
if (!query) return BASE + path
|
||||
const qs = new URLSearchParams()
|
||||
@@ -29,35 +51,170 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
return s ? `${BASE}${path}?${s}` : BASE + path
|
||||
}
|
||||
|
||||
async function parseError(resp: Response): Promise<never> {
|
||||
let message = `请求失败(${resp.status})`
|
||||
try {
|
||||
const body = (await resp.json()) as { error?: string; hint?: string }
|
||||
if (body.error) message = body.hint ? `${body.hint}|${body.error}` : body.error
|
||||
} catch {
|
||||
/* 非 JSON 响应体,保留默认消息 */
|
||||
}
|
||||
if (resp.status === 401) useAuthStore().logout()
|
||||
// 全局限速(429)整页拦截;登录接口的 429 是账号锁定,留在表单内提示
|
||||
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
|
||||
async function parseError(
|
||||
resp: Response,
|
||||
sentToken: string,
|
||||
): Promise<never> {
|
||||
const details = await readErrorDetails(resp)
|
||||
if (resp.status === 401) await handleUnauthorized(sentToken)
|
||||
if (resp.status === 429 && details.rateLimited && location.pathname !== '/blocked') {
|
||||
location.assign('/blocked')
|
||||
}
|
||||
throw new ApiError(resp.status, message)
|
||||
throw new ApiError(resp.status, details.message, details.ociCode)
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
|
||||
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
async function readErrorDetails(resp: Response): Promise<ErrorDetails> {
|
||||
const fallback = { message: `请求失败(${resp.status})`, rateLimited: false }
|
||||
try {
|
||||
const body = (await resp.json()) as {
|
||||
error?: string
|
||||
hint?: string
|
||||
errors?: unknown[]
|
||||
ociCode?: string
|
||||
code?: string
|
||||
}
|
||||
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
|
||||
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 {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 处理:仅当失败请求发送时的 token 仍是当前会话 token 才登出——换发新
|
||||
* token 后(OAuth 绑定回跳等),旧 token 请求的迟到 401 不得清掉新会话;
|
||||
* 登出后带 redirect 统一跳登录页,不把用户留在满屏报错的页面上。 */
|
||||
async function handleUnauthorized(sentToken: string) {
|
||||
const waited = await waitForSessionRefresh(sentToken)
|
||||
if (waited) await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
const auth = useAuthStore()
|
||||
if (sentToken !== auth.token) return
|
||||
auth.logout()
|
||||
if (location.pathname !== '/login') {
|
||||
const redirect = encodeURIComponent(location.pathname + location.search)
|
||||
location.assign(`/login?redirect=${redirect}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 写请求(非 GET)在飞行中的去重表:key → 共享 Promise */
|
||||
const inflightWrites = new Map<string, Promise<unknown>>()
|
||||
|
||||
function beginSessionRefresh(token: string) {
|
||||
const state = sessionRefreshes.get(token)
|
||||
if (state) {
|
||||
state.count++
|
||||
return
|
||||
}
|
||||
sessionRefreshes.set(token, { count: 1, waiters: [] })
|
||||
}
|
||||
|
||||
function endSessionRefresh(token: string) {
|
||||
const state = sessionRefreshes.get(token)
|
||||
if (!state) return
|
||||
state.count--
|
||||
state.waiters = state.waiters.filter((waiter) => {
|
||||
if (state.count > 0) return true
|
||||
waiter.resolve(true)
|
||||
return false
|
||||
})
|
||||
if (state.count === 0) sessionRefreshes.delete(token)
|
||||
}
|
||||
|
||||
function waitForSessionRefresh(token: string): Promise<boolean> {
|
||||
const state = sessionRefreshes.get(token)
|
||||
if (!state || state.count === 0) return Promise.resolve(false)
|
||||
return new Promise((resolve) => state.waiters.push({ resolve }))
|
||||
}
|
||||
|
||||
function trackSessionRefresh(token: string): () => void {
|
||||
beginSessionRefresh(token)
|
||||
let active = true
|
||||
return () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
endSessionRefresh(token)
|
||||
}
|
||||
}
|
||||
|
||||
function applySessionRefresh(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return
|
||||
const refresh = value as { token?: unknown; expiresAt?: unknown }
|
||||
if (typeof refresh.token !== 'string' || typeof refresh.expiresAt !== 'string') return
|
||||
useAuthStore().setSession(refresh.token, refresh.expiresAt)
|
||||
}
|
||||
|
||||
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError;
|
||||
* 非 GET 请求按 token+method+url+body 做 in-flight 去重——前一发未返回时复用同一 Promise,
|
||||
* 防连点造成重复提交;完成(无论成败)即移除。GET 不去重(useAsync 已有后发优先语义)。
|
||||
* 键含认证上下文:token 换发/切换账号后不得复用旧会话在飞行中的请求 */
|
||||
export function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method = (opts.method ?? 'GET').toUpperCase()
|
||||
if (method === 'GET') return doRequest<T>(path, opts)
|
||||
const body = opts.body === undefined ? '' : JSON.stringify(opts.body)
|
||||
const sentToken = useAuthStore().token ?? ''
|
||||
const key = `${sentToken} ${method} ${buildUrl(path, opts.query)} ${body}`
|
||||
const existing = inflightWrites.get(key)
|
||||
if (existing) return existing as Promise<T>
|
||||
const tracksRefresh = opts.refreshesSession === true && sentToken !== ''
|
||||
const finishRefresh = tracksRefresh ? trackSessionRefresh(sentToken) : () => {}
|
||||
const p = doRequest<T>(path, opts, finishRefresh).finally(() => {
|
||||
inflightWrites.delete(key)
|
||||
finishRefresh()
|
||||
})
|
||||
inflightWrites.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
async function doRequest<T>(
|
||||
path: string,
|
||||
opts: RequestOptions,
|
||||
finishRefresh: () => void = () => {},
|
||||
): Promise<T> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
const sentToken = auth.token
|
||||
const resp = await fetch(buildUrl(path, opts.query), {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
})
|
||||
if (!resp.ok) await parseError(resp)
|
||||
if (resp.status === 204) return undefined as T
|
||||
return (await resp.json()) as T
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401) finishRefresh()
|
||||
await parseError(resp, sentToken)
|
||||
}
|
||||
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
|
||||
const text = await resp.text()
|
||||
const result = (text ? JSON.parse(text) : undefined) as T
|
||||
if (opts.refreshesSession && sentToken) applySessionRefresh(result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** 原始请求:注入 JWT、错误同 request 转 ApiError,返回原始 Response(二进制内容读写用) */
|
||||
export async function rawFetch(
|
||||
path: string,
|
||||
opts: {
|
||||
method?: string
|
||||
query?: RequestOptions['query']
|
||||
headers?: Record<string, string>
|
||||
body?: BodyInit
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const auth = useAuthStore()
|
||||
const headers: Record<string, string> = { ...opts.headers }
|
||||
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
|
||||
const sentToken = auth.token
|
||||
const resp = await fetch(buildUrl(path, opts.query), {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body,
|
||||
})
|
||||
if (!resp.ok) await parseError(resp, sentToken)
|
||||
return resp
|
||||
}
|
||||
|
||||
export const mockOn = import.meta.env.VITE_MOCK === '1'
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mockReservedIps } from './mock'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
|
||||
export function listReservedIps(
|
||||
cfgId: number,
|
||||
region?: string,
|
||||
compartmentId?: string,
|
||||
): Promise<ReservedIp[]> {
|
||||
if (mockOn) return mocked([...mockReservedIps])
|
||||
return request(`/oci-configs/${cfgId}/reserved-ips`, { query: { region, compartmentId } })
|
||||
}
|
||||
|
||||
export function createReservedIp(
|
||||
cfgId: number,
|
||||
region?: string,
|
||||
displayName?: string,
|
||||
compartmentId?: string,
|
||||
): Promise<ReservedIp> {
|
||||
if (mockOn) {
|
||||
const ip: ReservedIp = {
|
||||
id: `ocid1.publicip..${Date.now()}`,
|
||||
displayName: displayName || 'reserved-ip',
|
||||
ipAddress: '155.248.201.77',
|
||||
lifecycleState: 'AVAILABLE',
|
||||
assignedInstanceId: '',
|
||||
assignedInstanceName: '',
|
||||
timeCreated: new Date().toISOString(),
|
||||
}
|
||||
mockReservedIps.push(ip)
|
||||
return mocked(ip, 600)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/reserved-ips`, {
|
||||
method: 'POST',
|
||||
body: { region: region ?? '', compartmentId: compartmentId ?? '', displayName },
|
||||
})
|
||||
}
|
||||
|
||||
/** vnicId 非空绑到该网卡,否则绑 instanceId 主网卡;两者都空表示解绑 */
|
||||
export function assignReservedIp(
|
||||
cfgId: number,
|
||||
publicIpId: string,
|
||||
instanceId: string,
|
||||
region?: string,
|
||||
vnicId?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
const found = mockReservedIps.find((r) => r.id === publicIpId)
|
||||
if (found) {
|
||||
found.assignedInstanceId = instanceId || (vnicId ? 'ocid1.instance.oc1..byvnic' : '')
|
||||
found.assignedInstanceName = instanceId || vnicId ? 'mock-instance' : ''
|
||||
}
|
||||
return mocked(undefined, 500)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/reserved-ips/${encodeURIComponent(publicIpId)}`, {
|
||||
method: 'PUT',
|
||||
body: { region: region ?? '', instanceId, vnicId: vnicId ?? '' },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteReservedIp(
|
||||
cfgId: number,
|
||||
publicIpId: string,
|
||||
region?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) {
|
||||
const idx = mockReservedIps.findIndex((r) => r.id === publicIpId)
|
||||
if (idx >= 0) mockReservedIps.splice(idx, 1)
|
||||
return mocked(undefined)
|
||||
}
|
||||
return request(`/oci-configs/${cfgId}/reserved-ips/${encodeURIComponent(publicIpId)}`, {
|
||||
method: 'DELETE',
|
||||
query: { region },
|
||||
})
|
||||
}
|
||||
+4
-4
@@ -139,13 +139,13 @@ export function getSecuritySetting(): Promise<SecuritySetting> {
|
||||
return request('/settings/security')
|
||||
}
|
||||
|
||||
/** 保存安全设置,返回最新值;越界或非法后端返回 400,保存后立即生效 */
|
||||
export function updateSecuritySetting(body: SecuritySetting): Promise<SecuritySetting> {
|
||||
/** 保存安全设置字段补丁(只落库出现字段),返回最新全量;越界或非法后端返回 400 */
|
||||
export function updateSecuritySetting(patch: Partial<SecuritySetting>): Promise<SecuritySetting> {
|
||||
if (mockOn) {
|
||||
Object.assign(mockSecuritySetting, body)
|
||||
Object.assign(mockSecuritySetting, patch)
|
||||
return mocked({ ...mockSecuritySetting }, 400)
|
||||
}
|
||||
return request('/settings/security', { method: 'PUT', body })
|
||||
return request('/settings/security', { method: 'PATCH', body: patch })
|
||||
}
|
||||
|
||||
export interface SystemLogParams {
|
||||
|
||||
+2
-5
@@ -66,10 +66,7 @@ export function listTaskLogs(id: number, limit = 50): Promise<TaskLog[]> {
|
||||
return request(`/tasks/${id}/logs`, { query: { limit } })
|
||||
}
|
||||
|
||||
export function runTask(id: number): Promise<TaskLog> {
|
||||
if (mockOn) {
|
||||
const logs = mockTaskLogs[id]
|
||||
return mocked(logs?.[0] ?? { id: 0, taskId: id, success: true, message: 'ok', durationMs: 100, createdAt: '' }, 1000)
|
||||
}
|
||||
export function runTask(id: number): Promise<{ triggered: boolean }> {
|
||||
if (mockOn) return mocked({ triggered: true }, 300)
|
||||
return request(`/tasks/${id}/run`, { method: 'POST' })
|
||||
}
|
||||
|
||||
+100
-17
@@ -10,14 +10,16 @@ import {
|
||||
mockUserDetails,
|
||||
mockUsers,
|
||||
} from './mock'
|
||||
import { mockOn, mocked, request } from './request'
|
||||
import { mockOn, mocked, rawFetch, request } from './request'
|
||||
import type {
|
||||
AuditEventsResult,
|
||||
CreatedApiKey,
|
||||
IamUser,
|
||||
IdentityDomain,
|
||||
IamUserDetail,
|
||||
IdentityProvider,
|
||||
IdentitySetting,
|
||||
IdpIconUpload,
|
||||
LimitItem,
|
||||
LimitService,
|
||||
NotificationRecipients,
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
SignOnRule,
|
||||
SubscriptionDetail,
|
||||
SubscriptionSummary,
|
||||
UserApiKey,
|
||||
} from '@/types/api'
|
||||
|
||||
// ---- 配额 ----
|
||||
@@ -168,31 +171,84 @@ export function clearUserMfa(id: number, userId: string, domainId?: string): Pro
|
||||
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } })
|
||||
}
|
||||
|
||||
export function deleteUserApiKeys(
|
||||
id: number,
|
||||
userId: string,
|
||||
includeCurrent = false,
|
||||
): Promise<{ deletedApiKeys: number }> {
|
||||
if (mockOn) return mocked({ deletedApiKeys: 1 })
|
||||
return request(`/oci-configs/${id}/users/${userId}/api-keys`, {
|
||||
// ---- 用户 API 签名 Key ----
|
||||
|
||||
const mockCreatedKey: CreatedApiKey = {
|
||||
fingerprint: '11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff',
|
||||
privateKey: '-----BEGIN RSA PRIVATE KEY-----\nMOCKMOCKMOCK\n-----END RSA PRIVATE KEY-----\n',
|
||||
configIni:
|
||||
'[DEFAULT]\nuser=ocid1.user.oc1..mock\nfingerprint=11:22:33:44:55:66:77:88:99:00:aa:bb:cc:dd:ee:ff\ntenancy=ocid1.tenancy.oc1..mock\nregion=ap-tokyo-1\nkey_file=~/.oci/oci_api_key.pem\n',
|
||||
}
|
||||
|
||||
export function listUserApiKeys(id: number, userId: string): Promise<{ items: UserApiKey[] }> {
|
||||
if (mockOn)
|
||||
return mocked({
|
||||
items: [
|
||||
{
|
||||
fingerprint: 'aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99',
|
||||
timeCreated: '2026-07-01T08:00:00Z',
|
||||
isCurrent: true,
|
||||
configIni: mockCreatedKey.configIni,
|
||||
},
|
||||
{
|
||||
fingerprint: '99:88:77:66:55:44:33:22:11:00:ff:ee:dd:cc:bb:aa',
|
||||
timeCreated: '2026-07-10T08:00:00Z',
|
||||
isCurrent: false,
|
||||
configIni: mockCreatedKey.configIni,
|
||||
},
|
||||
],
|
||||
})
|
||||
return request(`/oci-configs/${id}/users/${userId}/api-keys`)
|
||||
}
|
||||
|
||||
/** 后端生成密钥对并上传公钥;私钥仅本次返回 */
|
||||
export function addUserApiKey(id: number, userId: string): Promise<CreatedApiKey> {
|
||||
if (mockOn) return mocked({ ...mockCreatedKey })
|
||||
return request(`/oci-configs/${id}/users/${userId}/api-keys`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function deleteUserApiKey(id: number, userId: string, fingerprint: string): Promise<void> {
|
||||
if (mockOn) return mocked(undefined)
|
||||
return request(`/oci-configs/${id}/users/${userId}/api-keys/${encodeURIComponent(fingerprint)}`, {
|
||||
method: 'DELETE',
|
||||
query: { includeCurrent },
|
||||
})
|
||||
}
|
||||
|
||||
/** 把刚创建的 key 设为本配置签名凭据(验证可用后落库,不删旧 key);私钥为创建时下发的那份回传;
|
||||
* userId 给定且异于当前签名用户时,一并把面板签名用户切换为该用户 */
|
||||
export function activateApiKey(
|
||||
id: number,
|
||||
fingerprint: string,
|
||||
privateKey: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
if (mockOn) return mocked(undefined)
|
||||
return request(`/oci-configs/${id}/activate-api-key`, {
|
||||
method: 'POST',
|
||||
body: { userId, fingerprint, privateKey },
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 审计日志 ----
|
||||
/** 批式懒加载查询审计事件:cursor 空自当前时刻首查,
|
||||
* 非空从上次响应游标向更早续取一批(~100 条,服务端分窗回溯) */
|
||||
* 非空从上次响应游标向更早续取一批(~200 条,服务端分窗回溯);
|
||||
* q 为服务端全文检索关键字,仅首查生效(续查沿用游标内嵌关键字) */
|
||||
export function listAuditEvents(
|
||||
id: number,
|
||||
opts: { region?: string; cursor?: string; limit?: number } = {},
|
||||
opts: { region?: string; cursor?: string; limit?: number; q?: string } = {},
|
||||
): Promise<AuditEventsResult> {
|
||||
// mock:首批带游标,续查一批后到尽头,演示懒加载链路
|
||||
if (mockOn)
|
||||
return mocked(
|
||||
{ items: mockAuditEvents, cursor: opts.cursor ? undefined : 'mock-cursor', exhausted: !!opts.cursor },
|
||||
300,
|
||||
// mock:首批带游标,续查一批后到尽头,演示懒加载链路;q 演示不区分大小写的包含匹配
|
||||
if (mockOn) {
|
||||
const kw = (opts.q ?? '').replace(/\*/g, '').toLowerCase()
|
||||
const items = kw
|
||||
? mockAuditEvents.filter((e) =>
|
||||
[e.eventName, e.resourceName, e.principalName, e.ipAddress, e.source].some((v) =>
|
||||
(v ?? '').toLowerCase().includes(kw),
|
||||
),
|
||||
)
|
||||
: mockAuditEvents
|
||||
return mocked({ items, cursor: opts.cursor ? undefined : 'mock-cursor', exhausted: !!opts.cursor }, 300)
|
||||
}
|
||||
return request(`/oci-configs/${id}/audit-events`, { query: { ...opts } })
|
||||
}
|
||||
|
||||
@@ -291,7 +347,7 @@ export interface CreateIdpBody {
|
||||
name: string
|
||||
metadata: string
|
||||
description?: string
|
||||
/** logo:data URI 或外链 URL */
|
||||
/** logo:http(s) 外链 URL(可先经 IdP 图标上传接口取得) */
|
||||
iconUrl?: string
|
||||
nameIdFormat?: string
|
||||
/** 非空表示按断言属性映射;空则按 SAML NameID 映射 */
|
||||
@@ -318,6 +374,33 @@ export function createIdp(id: number, body: CreateIdpBody, domainId?: string): P
|
||||
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
|
||||
}
|
||||
|
||||
/** 上传 IdP 图标到身份域公共图片存储,返回可填入 iconUrl 的公网地址(multipart 走 rawFetch) */
|
||||
export async function uploadIdpIcon(
|
||||
id: number,
|
||||
file: File,
|
||||
domainId?: string,
|
||||
): Promise<IdpIconUpload> {
|
||||
if (mockOn)
|
||||
return mocked({ url: 'https://mock.local/images/idp-icon.png', fileName: 'images/idp-icon.png' }, 600)
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, file.name)
|
||||
const resp = await rawFetch(`/oci-configs/${id}/idp-icons`, {
|
||||
method: 'POST',
|
||||
query: { domainId },
|
||||
body: fd,
|
||||
})
|
||||
return (await resp.json()) as IdpIconUpload
|
||||
}
|
||||
|
||||
/** 删除尚未被 IdP 采用的身份域公共图片 */
|
||||
export function deleteIdpIcon(id: number, fileName: string, domainId?: string): Promise<void> {
|
||||
if (mockOn) return mocked(undefined)
|
||||
return request(`/oci-configs/${id}/idp-icons`, {
|
||||
method: 'DELETE',
|
||||
query: { domainId, fileName },
|
||||
})
|
||||
}
|
||||
|
||||
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
|
||||
if (mockOn) return mocked(mockIdps)
|
||||
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
--color-wash-deep: #ece9df; /* 侧栏激活 / 头像底 */
|
||||
--color-accent: #c96442;
|
||||
--color-accent-hover: #ad4f2e;
|
||||
--color-accent-pressed: #9a4527;
|
||||
--color-chart: #c15f3c;
|
||||
--color-ok: #788c5d;
|
||||
--color-err: #b53333;
|
||||
--color-err-hover: #9c2b2b;
|
||||
--color-err-pressed: #872525;
|
||||
--color-info: #6a9bcc;
|
||||
--color-warn: #a87514;
|
||||
|
||||
@@ -25,6 +28,9 @@
|
||||
-apple-system, 'SF Pro Text', 'Helvetica Neue', 'PingFang SC', 'Noto Sans SC', sans-serif;
|
||||
--font-serif: Georgia, 'Times New Roman', 'Songti SC', serif;
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
|
||||
/* 浮层(下拉/整页提示卡)统一投影:比卡片的 1px 分层重一档,仍保持克制 */
|
||||
--shadow-overlay: 0 8px 24px rgba(20, 20, 19, 0.1);
|
||||
}
|
||||
|
||||
/* 暗色主题:html.dark 由 stores/app.ts 挂载,色值与 tokens.ts 的 darkTokens 同步 */
|
||||
@@ -42,9 +48,12 @@ html.dark {
|
||||
--color-wash-deep: #33312d;
|
||||
--color-accent: #d97757;
|
||||
--color-accent-hover: #e08b6d;
|
||||
--color-accent-pressed: #c4674a;
|
||||
--color-chart: #d97757;
|
||||
--color-ok: #8fa574;
|
||||
--color-err: #d06060;
|
||||
--color-err-hover: #d97070;
|
||||
--color-err-pressed: #b85454;
|
||||
--color-info: #7fabd6;
|
||||
--color-warn: #c29135;
|
||||
color-scheme: dark;
|
||||
@@ -81,6 +90,49 @@ body {
|
||||
background: color-mix(in srgb, var(--color-ink) 38%, transparent);
|
||||
}
|
||||
|
||||
/* 表格滚动条常显:Naive 隐藏原生条(scrollbar-width:none)后全靠自绘 thumb,
|
||||
而自绘条仅悬停时短暂出现且渲染链路脆弱,窄容器下用户无从得知表格可横滚。
|
||||
现代 Chromium(121+)只认标准属性——computed 为 none 时 ::-webkit-* 规则
|
||||
整体失效,故必须以更高特异性覆盖 scrollbar-width 本身(样式随全局瘦滚动条);
|
||||
webkit 伪元素规则兜底老内核(Naive 把 scrollbar/thumb 清零,须逐项放开) */
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container::-webkit-scrollbar:horizontal {
|
||||
display: block;
|
||||
height: 7px;
|
||||
background: transparent;
|
||||
}
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container::-webkit-scrollbar-thumb:horizontal {
|
||||
display: block;
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-ink) 22%, transparent);
|
||||
}
|
||||
|
||||
/* raw 弹层(ConfirmPop / TenantPicker 等自带圆角卡片):Naive 的 .n-popover
|
||||
在 raw 模式下不加背景与圆角,但 box-shadow 无条件应用——外层直角矩形的
|
||||
阴影会在内层圆角卡四角露出方角,统一去掉外层阴影(卡片自带 shadow-overlay) */
|
||||
.n-popover--raw {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 租户选择器面板(移动端):popover follower 只会翻转不会平移,窄屏下
|
||||
bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切;
|
||||
改为覆盖 follower 定位,固定顶部整宽展示(follower 挂 body 下,无
|
||||
transform 祖先,fixed 相对视口生效) */
|
||||
@media (max-width: 767px) {
|
||||
.v-binder-follower-content:has(.tenant-picker-panel) {
|
||||
position: fixed !important;
|
||||
inset: 60px 12px auto 12px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.v-binder-follower-content:has(.tenant-picker-panel) .n-popover,
|
||||
.tenant-picker-panel {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面标题统一 serif */
|
||||
.page-title {
|
||||
font-family: var(--font-serif);
|
||||
@@ -296,3 +348,92 @@ html.theme-switching *::before,
|
||||
html.theme-switching *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 区间树形选择器菜单(teleport 至 body,scoped 样式不可达):
|
||||
容器留白,展开箭头与文本节奏对齐 Select 下拉 */
|
||||
.n-tree-select-menu .n-tree {
|
||||
padding: 5px 6px;
|
||||
}
|
||||
|
||||
/* 代码高亮配色(highlight.js 输出):贴设计 token 的低饱和土色系,
|
||||
预览弹窗与编辑器 overlay 共用;明暗两态随 token 自动切换 */
|
||||
.code-hl .hljs-comment,
|
||||
.code-hl .hljs-quote {
|
||||
color: var(--color-ink-3);
|
||||
font-style: italic;
|
||||
}
|
||||
.code-hl .hljs-keyword,
|
||||
.code-hl .hljs-selector-tag,
|
||||
.code-hl .hljs-meta {
|
||||
color: var(--color-warn);
|
||||
}
|
||||
.code-hl .hljs-string,
|
||||
.code-hl .hljs-regexp {
|
||||
color: var(--color-ok);
|
||||
}
|
||||
.code-hl .hljs-number,
|
||||
.code-hl .hljs-literal,
|
||||
.code-hl .hljs-symbol {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.code-hl .hljs-attr,
|
||||
.code-hl .hljs-attribute,
|
||||
.code-hl .hljs-property,
|
||||
.code-hl .hljs-section,
|
||||
.code-hl .hljs-selector-class,
|
||||
.code-hl .hljs-selector-id {
|
||||
color: var(--color-info);
|
||||
}
|
||||
.code-hl .hljs-title,
|
||||
.code-hl .hljs-name,
|
||||
.code-hl .hljs-built_in {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 移动端:Tab 条横向滑动;右缘渐隐暗示还有更多 Tab(Naive 无内建暗示),
|
||||
滚动内容补尾部内边距,滚到底时末个 Tab 不被渐隐吃掉 */
|
||||
@media (max-width: 767px) {
|
||||
.n-tabs .n-tabs-nav-scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
.n-tabs .n-tabs-nav-scroll-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.n-tabs .n-tabs-nav-scroll-content {
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗移动端全屏形态,FormModal 与各详情/表单弹窗共用(配 useMobileModal):
|
||||
NModal teleport 到 body,组件 scoped 样式够不到,且按路由分包后单组件的
|
||||
非 scoped 样式不保证已加载,故规则放全局。内容区吃满剩余高度限内滚动,
|
||||
页脚实底常驻含手势区安全距。内容区类名此版本为 .n-card-content(footer
|
||||
却是 .n-card__footer),两种拼写都盖住防升级回切 */
|
||||
.form-modal-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.form-modal-mobile .form-modal-body {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.form-modal-mobile .n-card-content,
|
||||
.form-modal-mobile .n-card__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 无内层滚动容器的弹窗(详情类)内容直接在此滚;FormModal 内层
|
||||
.form-modal-body 自会收缩到容器内,不触发此层滚动 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
.form-modal-mobile .n-card__footer {
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: var(--color-card);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { NTreeSelect, type TreeSelectOption } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { Compartment } from '@/types/api'
|
||||
|
||||
/** 区间选择器:按 parentId 组树,与官方控制台一致分层展示;
|
||||
* 父级不在列表中的节点视为根 compartment 的直接子级,根以 value='' 表示。 */
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string
|
||||
compartments: Compartment[]
|
||||
rootLabel: string
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
size?: 'small' | 'medium'
|
||||
}>(),
|
||||
{ size: 'small' },
|
||||
)
|
||||
const emit = defineEmits<{ 'update:value': [string] }>()
|
||||
|
||||
function groupByParent(list: Compartment[]): Map<string, Compartment[]> {
|
||||
const ids = new Set(list.map((c) => c.id))
|
||||
const map = new Map<string, Compartment[]>()
|
||||
for (const c of list) {
|
||||
const parent = ids.has(c.parentId) ? c.parentId : ''
|
||||
map.set(parent, [...(map.get(parent) ?? []), c])
|
||||
}
|
||||
for (const arr of map.values()) arr.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
||||
return map
|
||||
}
|
||||
|
||||
function toNodes(map: Map<string, Compartment[]>, parent: string): TreeSelectOption[] | undefined {
|
||||
const list = map.get(parent)
|
||||
if (!list) return undefined
|
||||
return list.map((c) => ({ label: c.name, key: c.id, children: toNodes(map, c.id) }))
|
||||
}
|
||||
|
||||
const tree = computed<TreeSelectOption[]>(() => {
|
||||
const map = groupByParent(props.compartments)
|
||||
return [{ label: props.rootLabel, key: '', children: toNodes(map, '') }]
|
||||
})
|
||||
|
||||
/** 选项异步加载,default-expand-all 不可靠,改为受控展开全部父节点 */
|
||||
const expandedKeys = ref<Array<string | number>>([])
|
||||
watch(
|
||||
tree,
|
||||
(nodes) => {
|
||||
const keys: string[] = []
|
||||
const walk = (list: TreeSelectOption[]) => {
|
||||
for (const n of list) {
|
||||
if (!n.children?.length) continue
|
||||
keys.push(String(n.key))
|
||||
walk(n.children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
expandedKeys.value = keys
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function onUpdate(v: string | number | Array<string | number> | null) {
|
||||
emit('update:value', typeof v === 'string' ? v : '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NTreeSelect
|
||||
class="min-w-0"
|
||||
:value="value"
|
||||
:options="tree"
|
||||
:expanded-keys="expandedKeys"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:size="size"
|
||||
:indent="18"
|
||||
:consistent-menu-width="false"
|
||||
filterable
|
||||
@update:value="onUpdate"
|
||||
@update:expanded-keys="(keys: Array<string | number>) => (expandedKeys = keys)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NPopover, type PopoverPlacement } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ConfirmKind = 'danger' | 'warn' | 'plain'
|
||||
|
||||
/**
|
||||
* 统一确认气泡:替代 NPopconfirm 默认样式(批次8 设计稿)。
|
||||
* onConfirm 返回 Promise 时确认按钮进入 loading,期间弹层不可关闭、不可重复提交。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
content?: string
|
||||
note?: string
|
||||
kind?: ConfirmKind
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
placement?: PopoverPlacement
|
||||
onConfirm?: () => unknown
|
||||
}>(),
|
||||
{
|
||||
kind: 'danger',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
placement: 'top',
|
||||
content: undefined,
|
||||
note: undefined,
|
||||
onConfirm: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const show = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
function onUpdateShow(v: boolean) {
|
||||
if (loading.value) return
|
||||
show.value = v
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (loading.value) return
|
||||
const r = props.onConfirm?.()
|
||||
if (r instanceof Promise) {
|
||||
loading.value = true
|
||||
try {
|
||||
await r
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
show.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover
|
||||
:show="show"
|
||||
trigger="click"
|
||||
:placement="placement"
|
||||
raw
|
||||
:show-arrow="false"
|
||||
@update:show="onUpdateShow"
|
||||
>
|
||||
<template #trigger><slot name="trigger" /></template>
|
||||
<div
|
||||
class="w-[268px] rounded-lg border border-line bg-white p-4 pb-3.5 shadow-overlay"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-[7px]"
|
||||
:class="
|
||||
kind === 'danger'
|
||||
? 'bg-err/12 text-err'
|
||||
: kind === 'warn'
|
||||
? 'bg-warn/14 text-warn'
|
||||
: 'bg-wash text-ink-2'
|
||||
"
|
||||
>
|
||||
<svg
|
||||
v-if="kind === 'danger'"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="kind === 'warn'"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</span>
|
||||
<!-- 超长资源名(如桶名)单行截断,悬停 tooltip 看全文 -->
|
||||
<span class="min-w-0 truncate text-[13.5px] font-semibold" :title="title">{{ title }}</span>
|
||||
</div>
|
||||
<div v-if="content" class="mt-2 pl-9 text-[12.5px] leading-relaxed text-ink-2">
|
||||
{{ content }}
|
||||
</div>
|
||||
<div v-if="note" class="mt-1 pl-9 text-xs leading-normal text-ink-3">{{ note }}</div>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<NButton size="small" :disabled="loading" @click="show = false">{{ cancelText }}</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:type="kind === 'danger' ? 'error' : 'primary'"
|
||||
:loading="loading"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
/** 筛选器统一布局:桌面 flex-wrap 换行,移动端横滚一行 + 右缘渐隐暗示,
|
||||
* 条目不换行不压缩(替代 max-md:!w-full 逐行堆叠吃满竖向空间的旧模式) */
|
||||
const isMobile = useIsMobile()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isMobile ? 'filter-scroll' : 'flex flex-wrap items-center gap-x-3 gap-y-2'">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
|
||||
padding-right: 24px;
|
||||
}
|
||||
.filter-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.filter-scroll > :deep(*) {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NModal } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
submitting?: boolean
|
||||
@@ -12,6 +15,15 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
/** 移动端全屏(100dvh 覆盖底栏与手势区),桌面维持定宽居中 */
|
||||
const modalStyle = computed(() =>
|
||||
isMobile.value
|
||||
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
|
||||
: { width: `${props.width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,9 +31,11 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
:style="{ width: `${width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="isMobile ? 'form-modal-mobile' : ''"
|
||||
:mask-closable="!submitting"
|
||||
:close-on-esc="!submitting"
|
||||
:closable="!submitting"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<!-- 标题插槽:需要在标题旁放徽章等富内容时覆盖 -->
|
||||
@@ -33,11 +47,17 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<NButton size="small" :disabled="submitting" @click="emit('update:show', false)">
|
||||
<NButton
|
||||
:size="isMobile ? 'medium' : 'small'"
|
||||
:class="isMobile ? 'flex-1' : ''"
|
||||
:disabled="submitting"
|
||||
@click="emit('update:show', false)"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:size="isMobile ? 'medium' : 'small'"
|
||||
:class="isMobile ? 'flex-1' : ''"
|
||||
:type="danger ? 'error' : 'primary'"
|
||||
:loading="submitting"
|
||||
:disabled="submitDisabled"
|
||||
@@ -54,9 +74,19 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
/* 长表单不撑破视口:内容区限高滚动,页脚按钮常驻;
|
||||
负 margin + 等量 padding 让滚动条贴弹窗边缘(变量继承自 .n-card) */
|
||||
.form-modal-body {
|
||||
max-height: min(62vh, calc(100vh - 220px));
|
||||
overflow-y: auto;
|
||||
margin-inline: calc(-1 * var(--n-padding-left, 24px));
|
||||
padding-inline: var(--n-padding-left, 24px);
|
||||
}
|
||||
/* 限高只在桌面定宽形态生效:移动全屏由全局 .form-modal-mobile 的 flex:1
|
||||
接管高度。不能靠全局规则 max-height:none 覆盖——main.css 先于组件
|
||||
scoped 样式加载,同特异性下这里会反超,故直接用断点隔离 */
|
||||
@media (min-width: 768px) {
|
||||
.form-modal-body {
|
||||
max-height: min(62vh, calc(100vh - 220px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 移动端全屏形态 .form-modal-mobile 的规则在 main.css(全局):
|
||||
NModal teleport 到 body,scoped 够不到;且该类被各弹窗跨 chunk 复用 -->
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { NSpin } from 'naive-ui'
|
||||
|
||||
/** 移动端列表卡片化容器:接管加载 / 空态,卡片体由默认插槽渲染 */
|
||||
const props = defineProps<{
|
||||
items: T[]
|
||||
loading?: boolean
|
||||
itemKey: (item: T) => string | number
|
||||
emptyText?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-if="props.loading && !props.items.length"
|
||||
class="panel flex items-center justify-center py-10"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else-if="!props.items.length" class="panel py-10 text-center text-[13px] text-ink-3">
|
||||
{{ props.emptyText ?? '暂无数据' }}
|
||||
</div>
|
||||
<div v-for="it in props.items" v-else :key="props.itemKey(it)" class="panel px-4 py-3">
|
||||
<slot :item="it"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; sub?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div class="flex items-baseline gap-3">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<slot name="title-extra" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="sub" class="text-[13px] text-ink-3">{{ sub }}</span>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
/** 面板标准头:统一 px-4.5 py-3.5 内距、text-sm 标题、text-xs 弱化描述。
|
||||
* 右侧动作放默认插槽——固定在标题行右端,desc 独立成行,窄屏下按钮
|
||||
* 不随描述折行漂移(移动端按钮位置统一);标题行内的徽标/计数放
|
||||
* #title-extra;富文本描述放 #desc。 */
|
||||
defineProps<{ title: string; desc?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<slot name="title-extra" />
|
||||
</div>
|
||||
<div v-if="$slots.default" class="flex flex-none items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="desc || $slots.desc" class="mt-0.5 text-xs text-ink-3">
|
||||
<template v-if="desc">{{ desc }}</template>
|
||||
<slot name="desc" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { NPopover } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
|
||||
/** 租户悬停卡(AI 网关渠道 / 回传日志共用):别名、租户名称、主区域三行
|
||||
* 加「前往租户」入口;触发文本本身不承载跳转,避免误触。 */
|
||||
const props = defineProps<{
|
||||
cfgId: number
|
||||
/** 触发文本(渠道名 / 租户别名) */
|
||||
label: string
|
||||
alias?: string
|
||||
tenancyName?: string
|
||||
region?: string
|
||||
/** 触发文本附加类(截断约束等) */
|
||||
labelClass?: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
function go(e: MouseEvent) {
|
||||
// 表格行自身可能有点击行为(如回传日志展开详情),跳转不冒泡
|
||||
e.stopPropagation()
|
||||
void router.push({ name: 'tenant-detail', params: { id: props.cfgId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover trigger="hover" placement="top-start" :style="{ maxWidth: '320px' }">
|
||||
<template #trigger>
|
||||
<span
|
||||
class="cursor-help border-b border-dashed border-ink-3/50 text-[13px]"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex min-w-40 flex-col text-xs">
|
||||
<div class="pb-1.5 text-[13px] font-semibold break-all">{{ alias ?? label }}</div>
|
||||
<div v-if="tenancyName" class="mono border-t border-line-soft py-1.5 break-all text-ink-2">
|
||||
{{ tenancyName }}
|
||||
</div>
|
||||
<div v-if="region" class="border-t border-line-soft py-1.5 text-ink-2">
|
||||
{{ regionAlias(region) }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-t border-line-soft pt-1.5 text-left font-medium text-accent"
|
||||
@click="go"
|
||||
>
|
||||
前往租户
|
||||
</button>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, NPopover, NSpin } from 'naive-ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
|
||||
|
||||
import type { OciConfigSummary } from '@/types/api'
|
||||
|
||||
/** 租户选择器:Popover 面板(搜索 + 左分组导航 + 右租户列表),替代平铺下拉。
|
||||
* 单选(v-model:value)点行即选即关;多选(multiple + v-model:values)点行切换勾选。
|
||||
* 搜索命中时忽略左侧分组限定。 */
|
||||
defineOptions({ inheritAttrs: false })
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configs: OciConfigSummary[]
|
||||
@@ -23,6 +24,9 @@ const props = withDefaults(
|
||||
)
|
||||
const emit = defineEmits<{ 'update:value': [number | null]; 'update:values': [number[]] }>()
|
||||
|
||||
/** 根组件是 NPopover,class 透传会落到弹出面板上把宽度压扁;改为只作用于默认触发按钮 */
|
||||
const attrs = useAttrs()
|
||||
|
||||
/** 「未分组」导航项哨兵(空串已被「全部租户」占用) */
|
||||
const UNGROUPED = '__ungrouped__'
|
||||
|
||||
@@ -107,6 +111,7 @@ const showClear = computed(
|
||||
type="button"
|
||||
class="group flex w-full cursor-pointer items-center gap-2 rounded-lg border bg-white px-3 text-left transition-colors"
|
||||
:class="[
|
||||
attrs.class as string,
|
||||
size === 'small' ? 'h-7' : 'h-[34px]',
|
||||
show ? 'border-accent ring-2 ring-accent/15' : 'border-line hover:border-accent/60',
|
||||
]"
|
||||
@@ -138,7 +143,7 @@ const showClear = computed(
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="w-[min(560px,92vw)] overflow-hidden rounded-xl border border-line bg-card shadow-[0_18px_44px_rgba(20,20,19,0.16)]">
|
||||
<div class="tenant-picker-panel w-[min(560px,92vw)] overflow-hidden rounded-lg border border-line bg-card shadow-overlay">
|
||||
<div class="px-3.5 pt-3 pb-2.5">
|
||||
<NInput ref="searchInput" v-model:value="query" size="small" placeholder="搜索租户别名 / 租户名称…" clearable>
|
||||
<template #prefix>
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
addAiBlacklist,
|
||||
listAiChannelModels,
|
||||
syncAiChannelModels,
|
||||
testAiChannelModel,
|
||||
} from '@/api/aigateway'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiChannel, AiModelCacheItem } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; channel: AiChannel | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const rows = ref<AiModelCacheItem[]>([])
|
||||
const loading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const testing = ref('')
|
||||
const filter = ref('')
|
||||
/** 本地镜像验证模型:测试通过即时高亮,不等父级刷新 */
|
||||
const probeModel = ref('')
|
||||
|
||||
const CAP_ORDER = ['CHAT', 'EMBEDDING', 'RERANK', 'TTS']
|
||||
const CAP_LABEL: Record<string, string> = { CHAT: '对话', EMBEDDING: '向量', RERANK: '重排', TTS: '语音' }
|
||||
|
||||
/** 按能力分组 + 关键字过滤;仅对话组提供「测试」入口 */
|
||||
const groups = computed(() => {
|
||||
const kw = filter.value.trim().toLowerCase()
|
||||
const buckets = new Map<string, AiModelCacheItem[]>()
|
||||
for (const r of rows.value) {
|
||||
if (kw && !r.name.toLowerCase().includes(kw)) continue
|
||||
const cap = r.capability || 'CHAT'
|
||||
buckets.set(cap, [...(buckets.get(cap) ?? []), r])
|
||||
}
|
||||
const order = [...CAP_ORDER, ...[...buckets.keys()].filter((c) => !CAP_ORDER.includes(c))]
|
||||
return order
|
||||
.filter((cap) => buckets.has(cap))
|
||||
.map((cap) => ({ cap, label: CAP_LABEL[cap] ?? cap, items: buckets.get(cap)! }))
|
||||
})
|
||||
|
||||
const shownCount = computed(() => groups.value.reduce((n, g) => n + g.items.length, 0))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show && props.channel) {
|
||||
probeModel.value = props.channel.probeModel
|
||||
filter.value = ''
|
||||
void reload()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function reload() {
|
||||
if (!props.channel) return
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await listAiChannelModels(props.channel.id)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync() {
|
||||
if (!props.channel) return
|
||||
syncing.value = true
|
||||
try {
|
||||
rows.value = await syncAiChannelModels(props.channel.id)
|
||||
toast.success(`已同步 ${rows.value.length} 个模型`)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '同步失败')
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doTest(name: string) {
|
||||
if (!props.channel || testing.value) return
|
||||
testing.value = name
|
||||
try {
|
||||
const ch = await testAiChannelModel(props.channel.id, name)
|
||||
probeModel.value = ch.probeModel
|
||||
toast.success('测试通过', '已设为该渠道探测验证模型,后续探测优先使用')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '测试失败')
|
||||
} finally {
|
||||
testing.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function doBlacklist(name: string) {
|
||||
try {
|
||||
await addAiBlacklist(name)
|
||||
toast.success('已拉黑')
|
||||
void reload()
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '拉黑失败')
|
||||
}
|
||||
}
|
||||
</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">模型列表</div>
|
||||
<div class="mono mt-1 text-xs font-normal text-ink-3">{{ channel?.name ?? '' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<NInput v-model:value="filter" size="small" placeholder="过滤模型名…" clearable class="min-w-0 flex-1" />
|
||||
<span class="flex-none text-xs whitespace-nowrap text-ink-3">
|
||||
{{ filter ? `${shownCount} / ${rows.length}` : `${rows.length} 个模型` }}
|
||||
</span>
|
||||
<NButton size="small" :loading="syncing" @click="doSync">同步模型</NButton>
|
||||
</div>
|
||||
|
||||
<div class="-mx-2 max-h-[58vh] overflow-y-auto">
|
||||
<div v-if="loading" class="py-10 text-center text-xs text-ink-3">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-for="g in groups" :key="g.cap">
|
||||
<div class="px-3 pt-3 pb-1 text-[11.5px] font-semibold tracking-wide text-ink-3">
|
||||
{{ g.label }} <span class="font-normal">· {{ g.items.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="r in g.items"
|
||||
:key="r.name"
|
||||
class="group flex items-center gap-2 rounded-lg px-3 py-[5px] hover:bg-row-hover"
|
||||
>
|
||||
<span
|
||||
class="mono min-w-0 truncate text-[13px]"
|
||||
:class="r.deprecatedAt ? 'text-ink-3' : ''"
|
||||
:title="r.modelOcid"
|
||||
>
|
||||
{{ r.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="r.name === probeModel"
|
||||
class="flex flex-none items-center gap-1 rounded-[5px] bg-info/15 px-1.5 py-px text-[10.5px] font-medium text-info"
|
||||
>
|
||||
<span class="h-[5px] w-[5px] rounded-full bg-info" />验证模型
|
||||
</span>
|
||||
<span v-if="r.deprecatedAt" class="flex-none text-[11px] text-warn">弃用</span>
|
||||
<span class="min-w-0 flex-1" />
|
||||
<button
|
||||
v-if="g.cap === 'CHAT'"
|
||||
type="button"
|
||||
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs hover:bg-accent/10"
|
||||
:class="testing === r.name ? 'text-accent' : 'text-ink-3/70 group-hover:text-accent'"
|
||||
:disabled="!!testing"
|
||||
@click="doTest(r.name)"
|
||||
>
|
||||
{{ testing === r.name ? '测试中…' : '测试' }}
|
||||
</button>
|
||||
<ConfirmPop
|
||||
:title="`拉黑模型「${r.name}」?`"
|
||||
content="将从全部渠道剔除,同步与探测不再入库。"
|
||||
note="可在模型黑名单中移出后重新同步恢复"
|
||||
confirm-text="拉黑"
|
||||
:on-confirm="() => doBlacklist(r.name)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs text-ink-3/70 group-hover:text-err hover:bg-err/10"
|
||||
>
|
||||
拉黑
|
||||
</button>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!shownCount" class="py-10 text-center text-xs text-ink-3">
|
||||
{{ rows.length ? '无匹配模型' : '暂无模型缓存,点击右上「同步模型」拉取' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 border-t border-line-soft pt-2.5 text-[11.5px] leading-relaxed text-ink-3">
|
||||
<span class="font-medium text-ink-2">测试</span>:发 max_tokens=16
|
||||
试调,通过即设为该渠道探测验证模型(后续探测优先使用)并置渠道可用 ·
|
||||
<span class="font-medium text-ink-2">拉黑</span>:全局生效,从全部渠道剔除 ·
|
||||
非对话模型不提供测试
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
|
||||
import { createAiChannel, deleteAiChannel, probeAiChannel, syncAiChannelModels, updateAiChannel } from '@/api/aigateway'
|
||||
import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway'
|
||||
import { listCachedRegions } from '@/api/configs'
|
||||
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TenantHoverCard from '@/components/TenantHoverCard.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
@@ -22,8 +26,21 @@ const emit = defineEmits<{ changed: [] }>()
|
||||
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
/** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户);
|
||||
* 名称本身不承载跳转,单元格内截断 */
|
||||
function nameCell(r: AiChannel) {
|
||||
// scope 经 Pinia reactive 包装,useAsync 的 data ref 已深层解包,不能再 .value
|
||||
const cfg = (scope.configs.data ?? []).find((c) => c.id === r.ociConfigId)
|
||||
return h(TenantHoverCard, {
|
||||
cfgId: r.ociConfigId,
|
||||
label: r.name,
|
||||
alias: cfg?.alias,
|
||||
tenancyName: cfg?.tenancyName,
|
||||
region: cfg?.region,
|
||||
labelClass: 'inline-block max-w-full truncate align-bottom font-medium',
|
||||
})
|
||||
}
|
||||
|
||||
const PROBE_META: Record<Exclude<AiProbeStatus, ''>, { kind: 'run' | 'warn' | 'term' | 'stop'; label: string }> = {
|
||||
ok: { kind: 'run', label: '可用' },
|
||||
@@ -109,38 +126,40 @@ async function doProbe(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync(id: number) {
|
||||
try {
|
||||
const models = await syncAiChannelModels(id)
|
||||
toast.success(`已同步 ${models.length} 个模型`)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '同步失败')
|
||||
}
|
||||
// ---- 模型列表弹窗 ----
|
||||
const showModels = ref(false)
|
||||
const modelsChannel = ref<AiChannel | null>(null)
|
||||
|
||||
function openModels(ch: AiChannel) {
|
||||
modelsChannel.value = ch
|
||||
showModels.value = true
|
||||
}
|
||||
|
||||
/** 正在切换启用状态的渠道 id 集合,按行 loading 防连点 */
|
||||
const toggling = ref(new Set<number>())
|
||||
|
||||
async function toggle(ch: AiChannel) {
|
||||
if (toggling.value.has(ch.id)) return
|
||||
toggling.value.add(ch.id)
|
||||
try {
|
||||
await updateAiChannel(ch.id, { enabled: !ch.enabled })
|
||||
toast.success(ch.enabled ? '已停用' : '已启用')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
toggling.value.delete(ch.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(ch: AiChannel) {
|
||||
dialog.warning({
|
||||
title: '删除渠道',
|
||||
content: `删除渠道「${ch.name}」及其模型缓存?`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
async function doRemove(ch: AiChannel) {
|
||||
try {
|
||||
await deleteAiChannel(ch.id)
|
||||
toast.success('已删除')
|
||||
emit('changed')
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function probeCell(r: AiChannel) {
|
||||
@@ -168,7 +187,7 @@ function probeBadge(r: AiChannel) {
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
{ title: '名称', key: 'name', minWidth: 200, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
|
||||
{ title: '名称', key: 'name', minWidth: 200, render: nameCell },
|
||||
{ title: '区域', key: 'region', width: 150, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs text-ink-2' }, r.region) },
|
||||
{
|
||||
title: '分组', key: 'group', width: 100,
|
||||
@@ -178,15 +197,28 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
},
|
||||
{ title: '优先级', key: 'priority', width: 76, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.priority)) },
|
||||
{ title: '权重', key: 'weight', width: 66, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.weight)) },
|
||||
{
|
||||
title: '模型数量', key: 'modelCount', width: 86,
|
||||
render: (r) => h('span', { class: r.modelCount ? 'mono text-[13px]' : 'text-xs text-ink-3' }, String(r.modelCount ?? 0)),
|
||||
},
|
||||
{ title: '探测状态', key: 'probeStatus', width: 120, render: probeCell },
|
||||
{
|
||||
title: '操作', key: 'actions', width: 250,
|
||||
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', loading: probing.value.has(r.id), onClick: () => doProbe(r.id) }, { default: () => '探测' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => doSync(r.id) }, { default: () => '同步模型' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openModels(r) }, { default: () => '模型列表' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除渠道「${r.name}」?`,
|
||||
content: '渠道及其模型缓存将一并删除。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => doRemove(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
@@ -194,21 +226,23 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">渠道(号池)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
渠道 = 租户 × 区域;请求按优先级分组、组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断
|
||||
</div>
|
||||
</div>
|
||||
<PanelHeader
|
||||
title="渠道(号池)"
|
||||
desc="渠道 = 租户 × 区域;按优先级与权重分流,失败自动重试并熔断"
|
||||
>
|
||||
<NButton size="small" type="primary" @click="openCreate">添加渠道</NButton>
|
||||
</div>
|
||||
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" />
|
||||
</PanelHeader>
|
||||
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" :scroll-x="960" />
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
探测三步:列模型(服务可见性)→ 同步模型缓存 → max_tokens=1 试调(配额)· 探测成功自动复位熔断 ·
|
||||
有渠道时系统自动维护「AI渠道探测」后台任务(每天 00:00 逐渠道探测,渠道清空自动暂停)
|
||||
探测 = 列模型 → 同步缓存 → 试调验证配额;成功自动复位熔断 · 后台任务每日 00:00 自动逐渠道探测
|
||||
</div>
|
||||
|
||||
<AiChannelModelsModal
|
||||
v-model:show="showModels"
|
||||
:channel="modelsChannel"
|
||||
@changed="emit('changed')"
|
||||
/>
|
||||
|
||||
<FormModal
|
||||
v-model:show="showForm"
|
||||
:title="editing ? '编辑渠道' : '添加渠道'"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
|
||||
import { NButton, NDataTable, NInput, NSelect, NTooltip, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
|
||||
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import GroupChips from '@/components/ai/GroupChips.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import type { AiKey, AiModel } from '@/types/api'
|
||||
@@ -16,7 +18,6 @@ const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[]; m
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
const dialog = useDialog()
|
||||
|
||||
// ---- 新建 / 编辑弹窗 ----
|
||||
const showForm = ref(false)
|
||||
@@ -99,28 +100,31 @@ async function copyKey() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 正在切换启用状态的密钥 id 集合,按行 loading 防连点 */
|
||||
const toggling = ref(new Set<number>())
|
||||
|
||||
async function toggle(key: AiKey) {
|
||||
if (toggling.value.has(key.id)) return
|
||||
toggling.value.add(key.id)
|
||||
try {
|
||||
await updateAiKey(key.id, { enabled: !key.enabled })
|
||||
message.success(key.enabled ? '已停用' : '已启用')
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
toggling.value.delete(key.id)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmRemove(key: AiKey) {
|
||||
dialog.warning({
|
||||
title: '删除密钥',
|
||||
content: `删除密钥「${key.name}」?使用该密钥的调用将立即失效。`,
|
||||
positiveText: '删除',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
async function doRemove(key: AiKey) {
|
||||
try {
|
||||
await deleteAiKey(key.id)
|
||||
message.success('已删除')
|
||||
emit('changed')
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const groupHints = computed(() => props.groups.filter(Boolean))
|
||||
@@ -159,9 +163,18 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
{
|
||||
title: '操作', key: 'actions', width: 170,
|
||||
render: (r) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, loading: toggling.value.has(r.id), onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
|
||||
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: `删除密钥「${r.name}」?`,
|
||||
content: '使用该密钥的调用将立即失效。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => doRemove(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }) },
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
@@ -169,16 +182,13 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">网关密钥</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
对外发放的访问凭证;明文仅创建时展示一次,库内只存哈希与尾 4 位;选了分组的密钥只在该分组渠道内负载均衡
|
||||
</div>
|
||||
</div>
|
||||
<PanelHeader
|
||||
title="网关密钥"
|
||||
desc="对外发放的访问凭证;明文仅创建时展示一次;分组密钥只走该分组渠道"
|
||||
>
|
||||
<NButton size="small" type="primary" @click="openCreate">新建密钥</NButton>
|
||||
</div>
|
||||
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" />
|
||||
</PanelHeader>
|
||||
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" :scroll-x="1110" />
|
||||
|
||||
<FormModal
|
||||
v-model:show="showForm"
|
||||
@@ -223,6 +233,7 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
<span class="text-xs text-ink-3">小时(上限 7 天)</span>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
:loading="togglingContentLog"
|
||||
:disabled="!contentLogHours"
|
||||
@click="setContentLog(contentLogHours ?? 0)"
|
||||
|
||||
@@ -4,8 +4,13 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { attachVnic } from '@/api/instances'
|
||||
import { listSubnets } from '@/api/networks'
|
||||
import { listReservedIps } from '@/api/reservedips'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import {
|
||||
freeReservedIpOptions,
|
||||
renderReservedIpLabel,
|
||||
} from '@/components/network/reservedIpSelect'
|
||||
import ToggleCard from '@/components/ToggleCard.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
@@ -29,10 +34,21 @@ const displayName = ref('')
|
||||
const privateIp = ref('')
|
||||
const assignPublicIp = ref(false)
|
||||
const assignIpv6 = ref(false)
|
||||
/** null = 临时公网 IP;选中保留 IP 时网卡就绪后由后台绑定 */
|
||||
const reservedIpId = ref<string | null>(null)
|
||||
|
||||
/** vcnId 传空列全部子网:次要网卡允许跨 VCN */
|
||||
const subnets = useAsync(() => listSubnets(props.cfgId, '', props.region), false)
|
||||
|
||||
const reservedIps = useAsync(async () => {
|
||||
try {
|
||||
return await listReservedIps(props.cfgId, props.region)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
const reservedOptions = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
@@ -42,11 +58,17 @@ watch(
|
||||
privateIp.value = ''
|
||||
assignPublicIp.value = false
|
||||
assignIpv6.value = false
|
||||
reservedIpId.value = null
|
||||
void subnets.run()
|
||||
void reservedIps.run()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(assignPublicIp, (on) => {
|
||||
if (!on) reservedIpId.value = null
|
||||
})
|
||||
|
||||
const options = computed<SelectOption[]>(() =>
|
||||
(subnets.data.value ?? [])
|
||||
.filter((s) => s.lifecycleState === 'AVAILABLE')
|
||||
@@ -75,10 +97,15 @@ async function submit() {
|
||||
subnetId: subnetId.value,
|
||||
displayName: displayName.value.trim(),
|
||||
privateIp: privateIp.value.trim(),
|
||||
assignPublicIp: assignPublicIp.value,
|
||||
assignPublicIp: assignPublicIp.value && !reservedIpId.value,
|
||||
assignIpv6: assignIpv6.value,
|
||||
reservedPublicIpId: reservedIpId.value ?? undefined,
|
||||
})
|
||||
message.success('附加请求已提交,OS 内需自行配置新网卡 IP')
|
||||
message.success(
|
||||
reservedIpId.value
|
||||
? '附加请求已提交,网卡就绪后自动绑定保留 IP;OS 内需自行配置新网卡 IP'
|
||||
: '附加请求已提交,OS 内需自行配置新网卡 IP',
|
||||
)
|
||||
emit('update:show', false)
|
||||
emit('attached')
|
||||
} catch (e) {
|
||||
@@ -128,6 +155,21 @@ async function submit() {
|
||||
:desc="publicIpBlocked ? '所选子网为私有子网,不可分配公网 IP' : '仅公有子网可用;临时地址,分离即释放'"
|
||||
:disabled="publicIpBlocked"
|
||||
/>
|
||||
<FormField
|
||||
v-if="assignPublicIp && !publicIpBlocked"
|
||||
label="公网 IP 类型"
|
||||
hint="选保留 IP 时网卡就绪后自动绑定,期间短暂无公网地址"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="reservedIpId"
|
||||
:options="reservedOptions"
|
||||
:loading="reservedIps.loading.value"
|
||||
:render-label="renderReservedIpLabel"
|
||||
:consistent-menu-width="false"
|
||||
clearable
|
||||
placeholder="临时 IP(默认);可选保留 IP"
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleCard
|
||||
v-model="assignIpv6"
|
||||
title="自动分配 IPv6"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { NRadio, NRadioGroup, NSelect } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { changeVnicPublicIp } from '@/api/instances'
|
||||
import { assignReservedIp, listReservedIps } from '@/api/reservedips'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { freeReservedIpOptions, renderReservedIpLabel } from '@/components/network/reservedIpSelect'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
/** 换公网 IP 弹窗(主卡/次卡通用):临时 IP 删旧分新,保留 IP 绑定固定地址;
|
||||
* 两种路径旧公网 IP 都由后端自动释放(临时删除、保留解绑)。 */
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number
|
||||
region?: string
|
||||
vnicId: string
|
||||
/** 网卡当前公网 IP,仅展示 */
|
||||
currentIp?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:show': [boolean]
|
||||
/** 换 IP 成功;newIp 为预期新地址,供父级做收敛轮询(拿不到时 undefined) */
|
||||
changed: [payload: { vnicId: string; newIp?: string }]
|
||||
}>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
const ipType = ref<'ephemeral' | 'reserved'>('ephemeral')
|
||||
const reservedId = ref<string | null>(null)
|
||||
const busy = ref(false)
|
||||
|
||||
const reservedIps = useAsync(async () => {
|
||||
try {
|
||||
return await listReservedIps(props.cfgId, props.region)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
const options = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
ipType.value = 'ephemeral'
|
||||
reservedId.value = null
|
||||
void reservedIps.run()
|
||||
},
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
const newIp = await applyChange()
|
||||
emit('update:show', false)
|
||||
emit('changed', { vnicId: props.vnicId, newIp })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 执行换 IP 并返回预期新地址;保留 IP 取所选项地址,拿不到时 undefined(轮询退化为比对旧值) */
|
||||
async function applyChange(): Promise<string | undefined> {
|
||||
if (ipType.value === 'reserved') {
|
||||
await assignReservedIp(props.cfgId, reservedId.value!, '', props.region, props.vnicId)
|
||||
message.success('保留 IP 已绑定,原公网 IP 已释放')
|
||||
return (reservedIps.data.value ?? []).find((r) => r.id === reservedId.value)?.ipAddress || undefined
|
||||
}
|
||||
const { publicIp } = await changeVnicPublicIp(props.cfgId, props.vnicId, props.region)
|
||||
message.success(`公网 IP 已更换为 ${publicIp}`)
|
||||
return publicIp
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FormModal
|
||||
:show="show"
|
||||
title="更换公网 IP"
|
||||
:width="440"
|
||||
:submitting="busy"
|
||||
:submit-disabled="ipType === 'reserved' && !reservedId"
|
||||
submit-text="更换"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@submit="submit"
|
||||
>
|
||||
<FormField v-if="currentIp" label="当前地址">
|
||||
<span class="mono text-[12.5px]">{{ currentIp }}</span>
|
||||
</FormField>
|
||||
<FormField label="新地址类型" hint="临时 IP 删除;保留 IP 解绑回到未分配">
|
||||
<NRadioGroup v-model:value="ipType">
|
||||
<NRadio value="ephemeral">临时 IP</NRadio>
|
||||
<NRadio value="reserved">保留 IP</NRadio>
|
||||
</NRadioGroup>
|
||||
</FormField>
|
||||
<FormField v-if="ipType === 'reserved'" label="选择保留 IP">
|
||||
<NSelect
|
||||
v-model:value="reservedId"
|
||||
:options="options"
|
||||
:loading="reservedIps.loading.value"
|
||||
:render-label="renderReservedIpLabel"
|
||||
:consistent-menu-width="false"
|
||||
placeholder="选择未绑定的保留 IP"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="!reservedIps.loading.value && !options.length" class="mt-1 text-xs text-ink-3">
|
||||
无可用保留 IP,可在「网络 → 保留 IP」面板创建
|
||||
</div>
|
||||
</FormField>
|
||||
</FormModal>
|
||||
</template>
|
||||
@@ -44,6 +44,11 @@ const canSubmit = computed(
|
||||
|
||||
async function submit() {
|
||||
if (props.cfgId === null || !specForm.value) return
|
||||
const spec = specForm.value.buildSpec()
|
||||
if (spec.reservedPublicIpId && form.count > 1) {
|
||||
message.warning('保留 IP 仅支持单台创建,请把数量改为 1')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createInstances(props.cfgId, {
|
||||
@@ -51,17 +56,18 @@ async function submit() {
|
||||
compartmentId: props.compartmentId || undefined,
|
||||
displayName: form.displayName.trim(),
|
||||
count: form.count,
|
||||
...specForm.value.buildSpec(),
|
||||
...spec,
|
||||
})
|
||||
// 后端 errors / instances 为空时序列化为 null,判空防炸
|
||||
const ok = result.instances?.length ?? 0
|
||||
const failed = result.errors?.length ?? 0
|
||||
if (failed) message.warning(`${ok} 台创建成功,${failed} 台失败:${result.errors![0]}`)
|
||||
if (failed) message.warning(`${ok} 台创建成功,${failed} 台失败`, result.errors!.join('\n'))
|
||||
else message.success(`已创建 ${ok} 台实例`)
|
||||
emit('update:show', false)
|
||||
emit('created')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
// 逐台错误(可能多行)收进「展开详情」,标题保持简短
|
||||
message.error('创建实例失败', e instanceof Error && e.message ? e.message : undefined)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
@@ -90,17 +96,18 @@ async function submit() {
|
||||
<InstanceSpecForm
|
||||
ref="specForm"
|
||||
sectioned
|
||||
auto-pick-ad
|
||||
:cfg-id="cfgId"
|
||||
:region="region"
|
||||
:compartment-id="compartmentId"
|
||||
/>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap items-center justify-between gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3"
|
||||
v-if="specForm?.summary"
|
||||
class="mt-3 rounded-lg border border-line-soft bg-wash px-3 py-2 text-xs leading-relaxed"
|
||||
>
|
||||
<span v-if="specForm?.summary" class="font-medium text-ink-2">
|
||||
<span class="font-medium text-ink-2">
|
||||
预估规格:{{ form.count }} 台 · {{ specForm.summary }}
|
||||
</span>
|
||||
<span>容量不足(Out of host capacity)时可改用抢机任务反复尝试</span>
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
|
||||
@@ -4,12 +4,15 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { listAvailabilityDomains, listImages, listShapes } from '@/api/instances'
|
||||
import { listSubnets, listVcns } from '@/api/networks'
|
||||
import { listReservedIps } from '@/api/reservedips'
|
||||
import { freeReservedIpOptions, renderReservedIpLabel } from '@/components/network/reservedIpSelect'
|
||||
import { listLimits } from '@/api/tenant'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormSection from '@/components/FormSection.vue'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { ComputeShape, LimitItem } from '@/types/api'
|
||||
|
||||
/** 实例规格表单(创建实例弹窗与抢机任务共用):
|
||||
* Shape / 可用域 / 规格 / 镜像 / VCN 子网 / 引导卷 / 登录方式 / IP 分配。
|
||||
@@ -22,6 +25,9 @@ const props = defineProps<{
|
||||
sectioned?: boolean
|
||||
/** 可用域字段说明;抢机任务留空为轮询语义,与手动创建(固定 AD-1)不同 */
|
||||
adHint?: string
|
||||
/** 创建场景:AD 留空(默认 AD-1)而所选 shape 在 AD-1 无配额时,自动落位首个有配额 AD;
|
||||
* 抢机表单留空 = 轮询语义,勿开 */
|
||||
autoPickAd?: boolean
|
||||
}>()
|
||||
|
||||
/** VCN 选择的「自动创建」哨兵:提交时不传 subnetId,由后端建 VCN 与子网 */
|
||||
@@ -30,14 +36,16 @@ const AUTO_VCN = '__auto__'
|
||||
const blank = {
|
||||
availabilityDomain: null as string | null,
|
||||
shape: 'VM.Standard.A1.Flex',
|
||||
ocpus: 4,
|
||||
memoryInGBs: 24,
|
||||
ocpus: 2,
|
||||
memoryInGBs: 12,
|
||||
imageId: null as string | null,
|
||||
vcnId: AUTO_VCN,
|
||||
subnetId: null as string | null,
|
||||
bootVolumeSizeGBs: null as number | null,
|
||||
bootVolumeVpusPerGB: 10,
|
||||
assignPublicIp: true,
|
||||
publicIpMode: 'ephemeral' as 'ephemeral' | 'reserved' | 'none',
|
||||
reservedPublicIpId: null as string | null,
|
||||
assignIpv6: true,
|
||||
authMode: 'password' as 'password' | 'ssh',
|
||||
randomPassword: true,
|
||||
@@ -169,6 +177,12 @@ function applySpec(spec: Record<string, unknown>) {
|
||||
if (typeof s.bootVolumeSizeGBs === 'number') form.bootVolumeSizeGBs = s.bootVolumeSizeGBs
|
||||
if (typeof s.bootVolumeVpusPerGB === 'number') form.bootVolumeVpusPerGB = s.bootVolumeVpusPerGB
|
||||
form.assignPublicIp = s.assignPublicIp !== false
|
||||
if (typeof s.reservedPublicIpId === 'string' && s.reservedPublicIpId) {
|
||||
form.publicIpMode = 'reserved'
|
||||
form.reservedPublicIpId = s.reservedPublicIpId
|
||||
} else {
|
||||
form.publicIpMode = s.assignPublicIp !== false ? 'ephemeral' : 'none'
|
||||
}
|
||||
form.assignIpv6 = s.assignIpv6 !== false
|
||||
form.authMode = typeof s.sshPublicKey === 'string' && s.sshPublicKey ? 'ssh' : 'password'
|
||||
form.sshPublicKey = typeof s.sshPublicKey === 'string' ? s.sshPublicKey : ''
|
||||
@@ -194,51 +208,82 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
/** shape → OCI compute 配额名(官方 Limits by Service 命名);已知映射用于配额标注,
|
||||
* 清单外的 shape 不判定配额。AD 维度多行求和 > 0 才认为有配额。 */
|
||||
const SHAPE_QUOTAS: Record<string, string> = {
|
||||
'VM.Standard.A1.Flex': 'standard-a1-core-count',
|
||||
'VM.Standard.A2.Flex': 'standard-a2-core-count',
|
||||
'VM.Standard.A4.Flex': 'standard-a4-core-count',
|
||||
'VM.Standard.E2.1.Micro': 'standard-e2-micro-core-count',
|
||||
'VM.Standard.E3.Flex': 'standard-e3-core-ad-count',
|
||||
'VM.Standard.E4.Flex': 'standard-e4-core-count',
|
||||
'VM.Standard.E5.Flex': 'standard-e5-core-count',
|
||||
'VM.Standard.E6.Flex': 'standard-e6-core-count',
|
||||
'VM.Standard3.Flex': 'standard3-core-count',
|
||||
/** 免费类计费类型:仅用于排序置顶,不在选项文案中标注 */
|
||||
const FREE_BILLING = new Set(['ALWAYS_FREE', 'LIMITED_FREE'])
|
||||
|
||||
/** shape 的核数配额名:quotaNames 排除 memory / reserved / reservable 变体,
|
||||
* 剩余为核数(或实例数)限额名,与 compute limits 的 name 同名 */
|
||||
function coreQuotaNames(s: ComputeShape): string[] {
|
||||
return (s.quotaNames ?? []).filter(
|
||||
(n) => !n.includes('memory') && !n.includes('reserved') && !n.includes('reservable'),
|
||||
)
|
||||
}
|
||||
|
||||
const BILLING_LABEL: Record<string, string> = {
|
||||
ALWAYS_FREE: '永久免费',
|
||||
LIMITED_FREE: '免费额度',
|
||||
/** 单个 shape 的配额汇总:AD 行按 AD 求和,REGION 行累计为区域总额;无匹配行返回 null */
|
||||
function shapeQuotaEntry(names: Set<string>, rows: LimitItem[]): ShapeQuota | null {
|
||||
const byAd = new Map<string, number>()
|
||||
let regional: number | null = null
|
||||
let matched = false
|
||||
for (const r of rows) {
|
||||
if (!names.has(r.name)) continue
|
||||
matched = true
|
||||
if (r.scopeType === 'AD' && r.availabilityDomain) {
|
||||
byAd.set(r.availabilityDomain, (byAd.get(r.availabilityDomain) ?? 0) + Math.max(r.value, 0))
|
||||
} else if (r.scopeType === 'REGION') {
|
||||
regional = (regional ?? 0) + Math.max(r.value, 0)
|
||||
}
|
||||
}
|
||||
return matched ? { byAd, regional } : null
|
||||
}
|
||||
|
||||
/** 已知映射 shape 的核数配额合计;未知映射或配额未加载返回 null(不判定) */
|
||||
function quotaOf(shape: string): number | null {
|
||||
const quota = SHAPE_QUOTAS[shape]
|
||||
const items = limits.data.value ?? []
|
||||
if (!quota || !items.length) return null
|
||||
return items.filter((l) => l.name === quota).reduce((sum, l) => sum + (l.value > 0 ? l.value : 0), 0)
|
||||
interface ShapeQuota {
|
||||
byAd: Map<string, number> // AD 全名 → 核数配额
|
||||
regional: number | null // 区域级总额,null = 无区域行
|
||||
}
|
||||
|
||||
/** 全部 shape 的配额汇总;quotaNames 缺失或 limits 无匹配行的 shape 无条目(不判定) */
|
||||
const quotaByShape = computed(() => {
|
||||
const rows = limits.data.value ?? []
|
||||
const out = new Map<string, ShapeQuota>()
|
||||
if (!rows.length) return out
|
||||
for (const s of shapes.data.value ?? []) {
|
||||
const names = new Set(coreQuotaNames(s))
|
||||
if (!names.size) continue
|
||||
const entry = shapeQuotaEntry(names, rows)
|
||||
if (entry) out.set(s.name, entry)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
/** shape 在指定 AD(null = 任一 AD)是否有配额;null = 数据不足不判定 */
|
||||
function quotaInAd(name: string, ad: string | null): boolean | null {
|
||||
const q = quotaByShape.value.get(name)
|
||||
if (!q) return null
|
||||
if (q.regional !== null && q.regional <= 0) return false
|
||||
if (!q.byAd.size) return true // 仅区域级限额且 > 0
|
||||
if (ad === null) return [...q.byAd.values()].some((v) => v > 0)
|
||||
return (q.byAd.get(ad) ?? 0) > 0
|
||||
}
|
||||
|
||||
/** 排序:免费 shape 优先 → VM.Standard 系列 → 其他 VM → 裸金属等 */
|
||||
function shapeRank(s: { billingType: string; name: string }): number {
|
||||
if (BILLING_LABEL[s.billingType]) return 0
|
||||
if (FREE_BILLING.has(s.billingType)) return 0
|
||||
if (s.name.startsWith('VM.Standard')) return 1
|
||||
if (s.name.startsWith('VM.')) return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
const shapeOptions = computed(() => {
|
||||
// 清单全部来自 shapes 接口,不做本地预设;接口不可用时仍可手输(tag 模式)
|
||||
// 清单全部来自 shapes 接口,不做本地预设;接口不可用时仍可手输(tag 模式);
|
||||
// 选定 AD 时按该 AD 判定配额,未选时看任一 AD
|
||||
const list = shapes.data.value ?? []
|
||||
const ad = form.availabilityDomain
|
||||
return [...list]
|
||||
.sort((a, b) => shapeRank(a) - shapeRank(b) || a.name.localeCompare(b.name))
|
||||
.map((s) => {
|
||||
const free = BILLING_LABEL[s.billingType]
|
||||
const quota = quotaOf(s.name)
|
||||
const suffix = (free ? `(${free})` : '') + (quota === 0 ? ' · 无配额' : '')
|
||||
return { label: s.name + suffix, value: s.name, disabled: quota === 0 }
|
||||
const ok = quotaInAd(s.name, ad)
|
||||
const suffix = ok === false ? (ad ? ' · 该 AD 无配额' : ' · 无配额') : ''
|
||||
return { label: s.name + suffix, value: s.name, disabled: ok === false }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -288,10 +333,29 @@ const imageOptions = computed(() => {
|
||||
.map((i) => ({ label: i.displayName, value: i.id }))
|
||||
})
|
||||
|
||||
/** AD 选项按当前 shape 的配额标注并禁用无配额项;数据不足时不标注 */
|
||||
const adOptions = computed(
|
||||
() => ads.data.value?.map((ad) => ({ label: shortAd(ad), value: ad })) ?? [],
|
||||
() =>
|
||||
ads.data.value?.map((ad) => {
|
||||
const ok = quotaInAd(form.shape, ad)
|
||||
return {
|
||||
label: shortAd(ad) + (ok === false ? '(无配额)' : ''),
|
||||
value: ad,
|
||||
disabled: ok === false,
|
||||
}
|
||||
}) ?? [],
|
||||
)
|
||||
|
||||
// 创建场景(autoPickAd):AD 留空将由后端取 AD-1;所选 shape 在 AD-1 无配额
|
||||
// 而其他 AD 有配额时,自动落位首个有配额 AD(字段可见,用户可改)
|
||||
watch([() => form.shape, quotaByShape, () => ads.data.value], () => {
|
||||
if (!props.autoPickAd || form.availabilityDomain !== null) return
|
||||
const list = ads.data.value ?? []
|
||||
if (!list.length || quotaInAd(form.shape, list[0]) !== false) return
|
||||
const target = list.find((ad) => quotaInAd(form.shape, ad) === true)
|
||||
if (target) form.availabilityDomain = target
|
||||
})
|
||||
|
||||
const vcnOptions = computed(() => [
|
||||
{ label: '自动创建(oci-portal-auto-vcn)', value: AUTO_VCN },
|
||||
...(vcns.data.value ?? []).map((v) => ({ label: v.displayName, value: v.id })),
|
||||
@@ -322,15 +386,37 @@ watch(ipv6Supported, (v) => {
|
||||
const valid = computed(() => {
|
||||
if (!form.imageId) return false
|
||||
if (form.vcnId !== AUTO_VCN && !form.subnetId) return false
|
||||
if (form.publicIpMode === 'reserved' && !form.reservedPublicIpId) return false
|
||||
return !isFlex.value || form.ocpus > 0
|
||||
})
|
||||
|
||||
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 + 免费标注 */
|
||||
// ---- 公网 IPv4 分配方式:临时 / 保留 / 不分配 ----
|
||||
const publicIpModes = [
|
||||
{ label: '临时 IP', value: 'ephemeral' },
|
||||
{ label: '保留 IP', value: 'reserved' },
|
||||
{ label: '不分配', value: 'none' },
|
||||
]
|
||||
const reservedIps = useAsync(async () => {
|
||||
if (props.cfgId === null) return []
|
||||
try {
|
||||
return await listReservedIps(props.cfgId, props.region || undefined, props.compartmentId || undefined)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
const freeReservedOptions = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
|
||||
watch(
|
||||
() => form.publicIpMode,
|
||||
(mode) => {
|
||||
if (mode === 'reserved') void reservedIps.run()
|
||||
else form.reservedPublicIpId = null
|
||||
},
|
||||
)
|
||||
|
||||
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 */
|
||||
const summary = computed(() => {
|
||||
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
|
||||
if (isFlex.value) parts.push(`${form.ocpus} OCPU · ${form.memoryInGBs} GB`)
|
||||
const free = selectedShape.value && BILLING_LABEL[selectedShape.value.billingType]
|
||||
if (free) parts.push(free)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
|
||||
@@ -347,7 +433,9 @@ function buildSpec(): Record<string, unknown> {
|
||||
bootVolumeSizeGBs: form.bootVolumeSizeGBs ?? undefined,
|
||||
// 性能独立于大小:留空按镜像默认大小时同样生效(后端仅镜像启动源使用)
|
||||
bootVolumeVpusPerGB: form.bootVolumeVpusPerGB || undefined,
|
||||
assignPublicIp: form.assignPublicIp,
|
||||
assignPublicIp: form.publicIpMode === 'ephemeral',
|
||||
reservedPublicIpId:
|
||||
form.publicIpMode === 'reserved' ? (form.reservedPublicIpId ?? undefined) : undefined,
|
||||
assignIpv6: form.assignIpv6,
|
||||
generateRootPassword: usePwd && form.randomPassword ? true : undefined,
|
||||
rootPassword:
|
||||
@@ -366,7 +454,7 @@ defineExpose({ valid, buildSpec, reset, applySpec, summary })
|
||||
<FormField
|
||||
label="Shape"
|
||||
required
|
||||
hint="清单来自租户当前区域实际提供的 shape(免费与无配额已标注),可手输其他"
|
||||
hint="清单来自租户当前区域实际提供的 shape(无配额已标注),可手输其他"
|
||||
>
|
||||
<NSelect
|
||||
v-model:value="form.shape"
|
||||
@@ -526,8 +614,23 @@ defineExpose({ valid, buildSpec, reset, applySpec, summary })
|
||||
placeholder="ssh-ed25519 AAAA…"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="公网 IPv4" hint="保留 IP:实例就绪后自动绑定,期间短暂无公网地址;仅单台创建可用">
|
||||
<NSelect v-model:value="form.publicIpMode" :options="publicIpModes" />
|
||||
</FormField>
|
||||
<FormField v-if="form.publicIpMode === 'reserved'" label="保留 IP" required>
|
||||
<NSelect
|
||||
v-model:value="form.reservedPublicIpId"
|
||||
:options="freeReservedOptions"
|
||||
:loading="reservedIps.loading.value"
|
||||
:render-label="renderReservedIpLabel"
|
||||
:consistent-menu-width="false"
|
||||
placeholder="选择未绑定的保留 IP"
|
||||
filterable
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-5">
|
||||
<NCheckbox v-model:checked="form.assignPublicIp">分配公网 IPv4</NCheckbox>
|
||||
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
|
||||
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
|
||||
</NCheckbox>
|
||||
|
||||
@@ -37,13 +37,25 @@ const daysOptions = [
|
||||
]
|
||||
|
||||
const series = computed(() => {
|
||||
const vnic = traffic.data.value?.vnics[0]
|
||||
if (!vnic) return null
|
||||
const labels = vnic.outbound.map((p) => p.timestamp.slice(5, 10))
|
||||
const vnics = traffic.data.value?.vnics ?? []
|
||||
if (!vnics.length) return null
|
||||
// 各 VNIC 时间序列长短不一(新挂网卡只有近几天),按日取并集逐日求和
|
||||
const byDay = new Map<string, { inbound: number; outbound: number }>()
|
||||
const bucket = (ts: string) => {
|
||||
const key = ts.slice(0, 10)
|
||||
let b = byDay.get(key)
|
||||
if (!b) byDay.set(key, (b = { inbound: 0, outbound: 0 }))
|
||||
return b
|
||||
}
|
||||
for (const v of vnics) {
|
||||
for (const p of v.inbound) bucket(p.timestamp).inbound += p.bytes
|
||||
for (const p of v.outbound) bucket(p.timestamp).outbound += p.bytes
|
||||
}
|
||||
const days = [...byDay.keys()].sort()
|
||||
return {
|
||||
labels,
|
||||
outbound: vnic.outbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
inbound: vnic.inbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
labels: days.map((d) => d.slice(5)),
|
||||
outbound: days.map((d) => +(byDay.get(d)!.outbound / 1024 ** 3).toFixed(2)),
|
||||
inbound: days.map((d) => +(byDay.get(d)!.inbound / 1024 ** 3).toFixed(2)),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -37,18 +37,10 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
const BILLING_LABEL: Record<string, string> = {
|
||||
ALWAYS_FREE: '永久免费',
|
||||
LIMITED_FREE: '免费额度',
|
||||
}
|
||||
|
||||
const shapeOptions = computed(() => {
|
||||
const list = shapes.data.value ?? []
|
||||
if (!list.length) return [{ label: form.shape, value: form.shape }]
|
||||
return list.map((s) => {
|
||||
const free = BILLING_LABEL[s.billingType]
|
||||
return { label: s.name + (free ? `(${free})` : ''), value: s.name }
|
||||
})
|
||||
return list.map((s) => ({ label: s.name, value: s.name }))
|
||||
})
|
||||
|
||||
const selectedShape = computed(
|
||||
@@ -141,7 +133,7 @@ async function submit() {
|
||||
</div>
|
||||
<div class="text-xs leading-relaxed text-ink-3">
|
||||
运行中实例 OCI 会自动重启完成变更;更换 shape 要求镜像与目标 shape 架构兼容(如 aarch64
|
||||
镜像不能换到 x86 shape)。A1 免费额度上限 4 OCPU / 24 GB。
|
||||
镜像不能换到 x86 shape)。
|
||||
</div>
|
||||
</FormModal>
|
||||
</template>
|
||||
|
||||
@@ -35,21 +35,34 @@ let ws: WebSocket | null = null
|
||||
let ro: ResizeObserver | null = null
|
||||
let sessionId = ''
|
||||
const encoder = new TextEncoder()
|
||||
// 打开代次:关闭/切换目标即自增。会话创建是长耗时异步,面板可能中途切到别的
|
||||
// 实例,旧代次的迟到响应只能就地删除其会话,不得覆盖共享状态——否则标题显示
|
||||
// B 实际连着 A,用户输入发往错误实例
|
||||
let openSeq = 0
|
||||
|
||||
async function open() {
|
||||
const seq = ++openSeq
|
||||
phase.value = 'creating'
|
||||
error.value = ''
|
||||
height.value = Math.max(280, Math.round(window.innerHeight * 0.42))
|
||||
let created = ''
|
||||
try {
|
||||
const res = await createConsoleSession(props.cfgId, props.instanceId, 'serial', props.region)
|
||||
sessionId = res.sessionId
|
||||
created = res.sessionId
|
||||
} catch (e) {
|
||||
if (seq !== openSeq) return
|
||||
phase.value = 'closed'
|
||||
error.value = e instanceof Error ? e.message : '创建串行会话失败'
|
||||
return
|
||||
}
|
||||
if (seq !== openSeq) {
|
||||
void deleteConsoleSession(created).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
sessionId = created
|
||||
phase.value = 'connecting'
|
||||
await nextTick()
|
||||
if (seq !== openSeq) return
|
||||
mountTerm()
|
||||
connectWs()
|
||||
}
|
||||
@@ -185,6 +198,7 @@ function startDrag(e: MouseEvent) {
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
openSeq++ // 使在途的 open 失效,其响应到达后自行删除会话
|
||||
ro?.disconnect()
|
||||
ro = null
|
||||
if (ws) {
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NPopconfirm, NSpin } from 'naive-ui'
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { addVnicIpv6, detachVnic, listVnics, removeIpv6 } from '@/api/instances'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import AttachVnicModal from '@/components/instance/AttachVnicModal.vue'
|
||||
import ChangeIpModal from '@/components/instance/ChangeIpModal.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import OcidText from '@/components/OcidText.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { ATTACHMENT_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Vnic } from '@/types/api'
|
||||
|
||||
/** 网卡(VNIC)区块:每卡一主行 + IPv6 副行;IPv6 增删按卡操作。
|
||||
* 删除复用实例级接口(后端按地址遍历全部网卡),添加走 per-VNIC 接口。 */
|
||||
@@ -27,12 +30,54 @@ const emit = defineEmits<{ changed: [] }>()
|
||||
const message = useToast()
|
||||
const vnics = useAsync(() => listVnics(props.cfgId, props.instanceId, props.region))
|
||||
|
||||
/** 附加已受理但 OCI 挂载列表有可见性延迟:限时轮询等新卡入列,入列后交由过渡态轮询接管 */
|
||||
const expectAtLeast = ref(0)
|
||||
let expectUntil = 0
|
||||
|
||||
useTransientPoll(
|
||||
() => vnics.data.value,
|
||||
(list) => !!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)),
|
||||
(list) =>
|
||||
!!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)) ||
|
||||
((list?.length ?? 0) < expectAtLeast.value && Date.now() < expectUntil) ||
|
||||
// 已 ATTACHED 但公网 IP 尚未回填(临时分配 / 保留 IP 后台绑定滞后),窗口期内继续轮询
|
||||
(Date.now() < expectUntil &&
|
||||
!!list?.some((v) => v.lifecycleState === 'ATTACHED' && !v.publicIp)) ||
|
||||
ipStale(list),
|
||||
() => void vnics.run({ silent: true }),
|
||||
)
|
||||
|
||||
// ---- 换 IP 收敛:OCI GetVnic 公网 IP 有秒级回填延迟,立即重查拿到的仍是旧值;
|
||||
// 换 IP 成功后开 90s 窗口轮询,直到目标网卡 IP 更新,再通知父级刷实例详情顶卡 ----
|
||||
const ipExpect = ref<{ vnicId: string; newIp?: string; oldIp?: string; until: number } | null>(null)
|
||||
|
||||
/** 目标网卡公网 IP 尚未刷新到预期值:窗口内视为过渡态继续轮询 */
|
||||
function ipStale(list: Vnic[] | null | undefined): boolean {
|
||||
const e = ipExpect.value
|
||||
if (!e || Date.now() > e.until) return false
|
||||
const v = (list ?? []).find((x) => x.vnicId === e.vnicId)
|
||||
if (!v) return false
|
||||
return e.newIp ? v.publicIp !== e.newIp : v.publicIp === e.oldIp
|
||||
}
|
||||
|
||||
function onIpChanged(p: { vnicId: string; newIp?: string }) {
|
||||
ipExpect.value = { ...p, oldIp: changeIpTarget.value.publicIp, until: Date.now() + 90_000 }
|
||||
refresh()
|
||||
}
|
||||
|
||||
// 收敛(或窗口过期)后清理并再通知父级:此刻数据源已是新 IP,顶卡才能刷到新值
|
||||
watch(
|
||||
() => vnics.data.value,
|
||||
(list) => {
|
||||
const e = ipExpect.value
|
||||
if (!e) return
|
||||
const expired = Date.now() > e.until
|
||||
if (expired || !ipStale(list)) {
|
||||
ipExpect.value = null
|
||||
if (!expired) emit('changed')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const showAttach = ref(false)
|
||||
const detaching = ref('')
|
||||
const addingIpv6 = ref('')
|
||||
@@ -44,6 +89,21 @@ function refresh() {
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
function onAttached() {
|
||||
expectAtLeast.value = (vnics.data.value ?? []).length + 1
|
||||
expectUntil = Date.now() + 90_000
|
||||
refresh()
|
||||
}
|
||||
|
||||
// ---- 换公网 IP:主卡/次卡统一走弹窗(可选临时或保留 IP) ----
|
||||
const showChangeIp = ref(false)
|
||||
const changeIpTarget = ref<{ vnicId: string; publicIp?: string }>({ vnicId: '' })
|
||||
|
||||
function openChangeIp(vnicId: string, publicIp?: string) {
|
||||
changeIpTarget.value = { vnicId, publicIp }
|
||||
showChangeIp.value = true
|
||||
}
|
||||
|
||||
async function doDetach(attachmentId: string) {
|
||||
detaching.value = attachmentId
|
||||
try {
|
||||
@@ -92,7 +152,7 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">网卡(VNIC)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
实例的全部虚拟网卡;每张网卡可各自附加多个 IPv6
|
||||
实例的全部虚拟网卡
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :disabled="disabled" @click="showAttach = true">
|
||||
@@ -115,7 +175,7 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
<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="w-28 border-b border-line-soft px-3 py-2 font-medium">状态</th>
|
||||
<th class="w-18 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
|
||||
<th class="w-30 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -144,37 +204,51 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td class="px-3 py-2.5 pb-1.5"><LifecycleBadge :state="v.lifecycleState" /></td>
|
||||
<td class="py-2.5 pr-4.5 pb-1.5 pl-3">
|
||||
<td class="py-2.5 pr-4.5 pb-1.5 pl-3 whitespace-nowrap">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || !v.vnicId || busy"
|
||||
@click="openChangeIp(v.vnicId, v.publicIp)"
|
||||
>
|
||||
换 IP
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="v.isPrimary"
|
||||
size="tiny"
|
||||
quaternary
|
||||
disabled
|
||||
title="主网卡不可分离"
|
||||
class="ml-1"
|
||||
>
|
||||
分离
|
||||
</NButton>
|
||||
<NPopconfirm v-else @positive-click="doDetach(v.attachmentId)">
|
||||
<ConfirmPop
|
||||
v-else
|
||||
title="分离该网卡?"
|
||||
content="其上全部 IP(含 IPv6)将一并释放。"
|
||||
confirm-text="分离"
|
||||
:on-confirm="() => doDetach(v.attachmentId)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:loading="detaching === v.attachmentId"
|
||||
class="ml-1"
|
||||
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || busy"
|
||||
>
|
||||
分离
|
||||
</NButton>
|
||||
</template>
|
||||
分离该网卡?其上全部 IP(含 IPv6)将一并释放
|
||||
</NPopconfirm>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b border-line-soft last:border-b-0">
|
||||
<td colspan="6" class="px-4.5 pt-0 pb-2.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[10.5px] font-medium tracking-wider text-ink-3">IPV6</span>
|
||||
<template v-if="v.vnicId">
|
||||
<template v-if="v.vnicId && v.lifecycleState === 'ATTACHED'">
|
||||
<span
|
||||
v-for="addr in v.ipv6Addresses ?? []"
|
||||
:key="addr"
|
||||
@@ -204,7 +278,9 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
+ IPv6
|
||||
</NButton>
|
||||
</template>
|
||||
<span v-else class="text-xs text-ink-3">无 · 附加完成后可添加</span>
|
||||
<span v-else class="text-xs text-ink-3">
|
||||
{{ v.vnicId ? '网卡未在挂载状态,不可操作 IPv6' : '无 · 附加完成后可添加' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -220,8 +296,7 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
</div>
|
||||
|
||||
<FootNote>
|
||||
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
|
||||
可多个(要求所在子网已启用 IPv6)· 次要网卡在 OS 内需自行配置 IP(dhclient / netplan)
|
||||
附加须实例运行中且 shape 有空余 VNIC 配额
|
||||
</FootNote>
|
||||
|
||||
<AttachVnicModal
|
||||
@@ -230,7 +305,16 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
:instance-id="instanceId"
|
||||
:region="region"
|
||||
:attached-count="(vnics.data.value ?? []).length"
|
||||
@attached="refresh"
|
||||
@attached="onAttached"
|
||||
/>
|
||||
|
||||
<ChangeIpModal
|
||||
v-model:show="showChangeIp"
|
||||
:cfg-id="cfgId"
|
||||
:region="region"
|
||||
:vnic-id="changeIpTarget.vnicId"
|
||||
:current-ip="changeIpTarget.publicIp"
|
||||
@changed="onIpChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,18 @@ import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { listAiCallLogs, listAiContentLogs } from '@/api/aigateway'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { AiCallLog, AiContentLog } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(600)
|
||||
const rows = ref<AiCallLog[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -21,6 +26,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -32,16 +41,22 @@ const pagination = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||
// 不得覆盖已切换的新页
|
||||
let loadSeq = 0
|
||||
|
||||
async function load(silent = false) {
|
||||
const seq = ++loadSeq
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const data = await listAiCallLogs({ page: pagination.page, size: pagination.pageSize })
|
||||
if (seq !== loadSeq) return
|
||||
rows.value = data.items
|
||||
pagination.itemCount = data.total
|
||||
} catch {
|
||||
// 自动刷新失败静默,手动路径由全局兜底
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
if (!silent && seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +84,7 @@ function statusCell(row: AiCallLog) {
|
||||
function tokensCell(row: AiCallLog) {
|
||||
if (row.errMsg && !row.totalTokens) return h('span', { class: 'text-[13px] text-ink-3' }, '—')
|
||||
const flow = row.stream ? ' · 流' : ''
|
||||
return h('span', { class: 'mono text-[13px]' }, `${row.promptTokens} / ${row.completionTokens}${flow}`)
|
||||
return h('span', { class: 'mono text-[13px] whitespace-nowrap' }, `${row.promptTokens} / ${row.completionTokens}${flow}`)
|
||||
}
|
||||
|
||||
const columns: DataTableColumns<AiCallLog> = [
|
||||
@@ -79,7 +94,7 @@ const columns: DataTableColumns<AiCallLog> = [
|
||||
{ title: '模型', key: 'model', minWidth: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs' }, r.model) },
|
||||
{ title: '渠道', key: 'channelName', minWidth: 180, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px]' }, r.channelName || '—') },
|
||||
{ title: '状态', key: 'status', width: 80, render: statusCell },
|
||||
{ title: 'Tokens(入/出)', key: 'tokens', width: 150, render: tokensCell },
|
||||
{ title: 'Tokens(入/出)', key: 'tokens', width: 190, render: tokensCell },
|
||||
{ title: '耗时', key: 'latencyMs', width: 80, render: (r) => h('span', { class: 'mono text-[13px]' }, fmtLatency(r.latencyMs)) },
|
||||
{ title: '重试', key: 'retries', width: 60, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.retries)) },
|
||||
]
|
||||
@@ -97,12 +112,15 @@ const showDetail = ref(false)
|
||||
|
||||
// ---- 内容日志正文(仅密钥内容日志窗口期内的调用存在) ----
|
||||
const contentLog = ref<AiContentLog | null>(null)
|
||||
// 正文加载代次:快速连开两条日志时,先开那条的迟到正文不得盖住当前详情
|
||||
let contentSeq = 0
|
||||
|
||||
async function loadContent(callLogId: number) {
|
||||
const seq = ++contentSeq
|
||||
contentLog.value = null
|
||||
try {
|
||||
const { items } = await listAiContentLogs({ page: 1, size: 1, callLogId })
|
||||
contentLog.value = items[0] ?? null
|
||||
if (seq === contentSeq) contentLog.value = items[0] ?? null
|
||||
} catch {
|
||||
// 正文加载失败不阻塞元数据详情
|
||||
}
|
||||
@@ -194,10 +212,10 @@ const footerText = computed(() => {
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3">
|
||||
<div class="text-sm font-semibold">AI 调用日志</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">仅元数据与 token 用量,不记录对话内容;保留 90 天 / 5 万行 · 每 5 秒自动刷新</div>
|
||||
</div>
|
||||
<PanelHeader
|
||||
title="AI 调用日志"
|
||||
desc="仅元数据与 token 用量;保留 90 天 / 5 万行"
|
||||
/>
|
||||
<NDataTable
|
||||
remote
|
||||
:columns="columns"
|
||||
@@ -206,10 +224,11 @@ const footerText = computed(() => {
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
:row-props="rowProps"
|
||||
:scroll-x="1230"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -1,29 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NDataTable, NModal, NPopover, type DataTableColumns } from 'naive-ui'
|
||||
import { NDataTable, NModal, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { listConfigs } from '@/api/configs'
|
||||
import { listLogEvents } from '@/api/logevents'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TenantHoverCard from '@/components/TenantHoverCard.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { eventLabel, eventTail } from '@/composables/useEventLabel'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { LogEvent, OciConfigSummary } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(720)
|
||||
const message = useToast()
|
||||
const router = useRouter()
|
||||
const scope = useScopeStore()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
const rows = ref<LogEvent[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -48,6 +49,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -59,7 +64,12 @@ const pagination = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||
// 不得覆盖已切换的新页
|
||||
let loadSeq = 0
|
||||
|
||||
async function load(silent = false) {
|
||||
const seq = ++loadSeq
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const data = await listLogEvents({
|
||||
@@ -67,12 +77,13 @@ async function load(silent = false) {
|
||||
pageSize: pagination.pageSize,
|
||||
cfgId: cfgFilter.value ?? undefined,
|
||||
})
|
||||
if (seq !== loadSeq) return
|
||||
rows.value = data.items
|
||||
pagination.itemCount = data.total
|
||||
} catch (e) {
|
||||
if (!silent) message.error(e instanceof Error ? e.message : '加载回传日志失败')
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
if (!silent && seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,35 +98,17 @@ const aliasById = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
/** 租户单元格:点击跳租户详情(阻止冒泡到行详情);hover 只展示值不加标签 */
|
||||
/** 租户单元格:悬停出统一租户卡(别名/名称/主区域/前往租户),名称本身不跳转 */
|
||||
function renderTenant(row: LogEvent) {
|
||||
const label = aliasById.value.get(row.ociConfigId) ?? `#${row.ociConfigId}`
|
||||
const cfg = cfgById.value.get(row.ociConfigId)
|
||||
const link = h(
|
||||
'span',
|
||||
{
|
||||
class: 'cursor-pointer border-b border-dashed border-ink-3/50 text-[13px] hover:text-accent',
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
void router.push({ name: 'tenant-detail', params: { id: row.ociConfigId } })
|
||||
},
|
||||
},
|
||||
return h(TenantHoverCard, {
|
||||
cfgId: row.ociConfigId,
|
||||
label,
|
||||
)
|
||||
if (!cfg) return link
|
||||
return h(
|
||||
NPopover,
|
||||
{ trigger: 'hover', placement: 'right' },
|
||||
{
|
||||
trigger: () => link,
|
||||
default: () =>
|
||||
h('div', { class: 'flex flex-col gap-1 text-xs' }, [
|
||||
h('div', { class: 'font-medium text-[13px]' }, cfg.alias),
|
||||
h('div', { class: 'mono text-ink-2' }, cfg.tenancyName),
|
||||
h('div', { class: 'text-ink-2' }, regionAlias(cfg.region)),
|
||||
]),
|
||||
},
|
||||
)
|
||||
alias: cfg?.alias,
|
||||
tenancyName: cfg?.tenancyName,
|
||||
region: cfg?.region,
|
||||
})
|
||||
}
|
||||
|
||||
/** 摘要头标题:事件中文名 → 类型尾段 → 解析状态兜底 */
|
||||
@@ -167,9 +160,10 @@ const columns: DataTableColumns<LogEvent> = [
|
||||
render: renderTenant,
|
||||
},
|
||||
{
|
||||
// 固定宽:常见事件类型约 45 字符,弹性空间让给来源列
|
||||
title: '事件类型',
|
||||
key: 'eventType',
|
||||
minWidth: 280,
|
||||
width: 400,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) =>
|
||||
row.eventType
|
||||
@@ -186,9 +180,10 @@ const columns: DataTableColumns<LogEvent> = [
|
||||
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
|
||||
},
|
||||
{
|
||||
// 弹性列:实例名可较长,宽屏下尽量完整展示
|
||||
title: '来源',
|
||||
key: 'source',
|
||||
width: 160,
|
||||
minWidth: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, row.source || '—'),
|
||||
},
|
||||
@@ -211,27 +206,32 @@ useAutoRefresh(() => load(true))
|
||||
|
||||
<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">
|
||||
OCI 侧推送的关键审计事件(Connector Hub → Notifications → 面板 webhook)
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<PanelHeader title="回传日志" desc="OCI 侧推送的关键审计事件">
|
||||
<TenantPicker
|
||||
v-if="!isMobile"
|
||||
v-model:value="cfgFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部租户"
|
||||
class="w-52"
|
||||
placement="bottom-end"
|
||||
:configs="scope.configs.data ?? []"
|
||||
:loading="scope.configs.loading"
|
||||
@update:value="onFilter"
|
||||
/>
|
||||
</PanelHeader>
|
||||
<!-- 移动端筛选独立成行全宽,不与标题挤同一行 -->
|
||||
<div v-if="isMobile" class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<TenantPicker
|
||||
v-model:value="cfgFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部租户"
|
||||
class="w-full"
|
||||
:configs="scope.configs.data ?? []"
|
||||
:loading="scope.configs.loading"
|
||||
@update:value="onFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
remote
|
||||
@@ -240,18 +240,10 @@ useAutoRefresh(() => load(true))
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll-x="880"
|
||||
:max-height="480"
|
||||
:row-key="(r: LogEvent) => r.id"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
在租户详情「其他」tab 一键创建链路后,实例生命周期(Launch/Terminate/InstanceAction)、
|
||||
用户与凭据(Create/Delete/UpdateUser、Create/DeleteApiKey、UpdateUserCapabilities)、
|
||||
区域订阅与策略变更等关键审计事件将自动回传;消息按 MessageId 幂等去重,保留 90
|
||||
天、总量超 2 万条时删除最旧,关键事件另经「通知管理 → 云端事件」推送告警
|
||||
</FootNote>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -5,15 +5,19 @@ import { computed, h, reactive, ref } from 'vue'
|
||||
import { listSystemLogs } from '@/api/settings'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { actionLabel } from '@/composables/useActionLabel'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { SystemLog } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
const message = useToast()
|
||||
|
||||
const rows = ref<SystemLog[]>([])
|
||||
@@ -29,6 +33,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -40,7 +48,12 @@ const pagination = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
// 列表请求代次:翻页与自动刷新共用 load,旧响应(如刷新的第 1 页)晚到时
|
||||
// 不得覆盖已切换的新页
|
||||
let loadSeq = 0
|
||||
|
||||
async function load(silent = false) {
|
||||
const seq = ++loadSeq
|
||||
if (!silent) loading.value = true
|
||||
try {
|
||||
const data = await listSystemLogs({
|
||||
@@ -48,12 +61,13 @@ async function load(silent = false) {
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: applied.value || undefined,
|
||||
})
|
||||
if (seq !== loadSeq) return
|
||||
rows.value = data.items
|
||||
pagination.itemCount = data.total
|
||||
} catch (e) {
|
||||
if (!silent) message.error(e instanceof Error ? e.message : '加载系统日志失败')
|
||||
} finally {
|
||||
if (!silent) loading.value = false
|
||||
if (!silent && seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,19 +175,25 @@ useAutoRefresh(() => load(true))
|
||||
|
||||
<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>
|
||||
<PanelHeader title="系统日志" desc="面板写操作与登录成败留痕,用于审计与排障">
|
||||
<NInput
|
||||
v-if="!isMobile"
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索路径 / 用户名"
|
||||
class="!w-64"
|
||||
@keyup.enter="search"
|
||||
@clear="onClear"
|
||||
/>
|
||||
</PanelHeader>
|
||||
<!-- 移动端筛选独立成行全宽,不与标题挤同一行 -->
|
||||
<div v-if="isMobile" class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索路径 / 用户名,回车确认"
|
||||
class="max-w-60"
|
||||
placeholder="搜索路径 / 用户名"
|
||||
@keyup.enter="search"
|
||||
@clear="onClear"
|
||||
/>
|
||||
@@ -188,13 +208,7 @@ useAutoRefresh(() => load(true))
|
||||
:row-key="(r: SystemLog) => r.id"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
自动记录 secured 接口的全部写请求(POST / PUT /
|
||||
DELETE)与登录成败,只存方法与路径等元数据、绝不记录请求体;失败请求另存响应错误文案与
|
||||
User-Agent;日志保留 90 天,总量超 5 万条时自动删除最旧记录 · 点击行查看详情
|
||||
</FootNote>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NModal, NSelect, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
|
||||
import { listInstances } from '@/api/instances'
|
||||
import { assignReservedIp, createReservedIp, deleteReservedIp, listReservedIps } from '@/api/reservedips'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number | null; region?: string; compartmentId?: string }>()
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(420)
|
||||
|
||||
const ips = useAsync<ReservedIp[]>(async () => {
|
||||
if (!props.cfgId) return []
|
||||
try {
|
||||
return await listReservedIps(props.cfgId, props.region, props.compartmentId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
watch([() => props.cfgId, () => props.region, () => props.compartmentId], () => void ips.run(), {
|
||||
immediate: true,
|
||||
})
|
||||
|
||||
const creating = ref(false)
|
||||
|
||||
async function create() {
|
||||
if (!props.cfgId) return
|
||||
creating.value = true
|
||||
try {
|
||||
await createReservedIp(props.cfgId, props.region, undefined, props.compartmentId)
|
||||
message.success('保留 IP 已创建')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 绑定实例弹窗 ----
|
||||
const showBind = ref(false)
|
||||
const bindTarget = ref<ReservedIp | null>(null)
|
||||
const bindInstanceId = ref<string | null>(null)
|
||||
const binding = ref(false)
|
||||
|
||||
const instances = useAsync(async () => {
|
||||
if (!props.cfgId) return []
|
||||
try {
|
||||
return await listInstances(props.cfgId, props.region, props.compartmentId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, false)
|
||||
|
||||
const instanceOptions = computed(() =>
|
||||
(instances.data.value ?? [])
|
||||
.filter((i) => !['TERMINATED', 'TERMINATING'].includes(i.lifecycleState))
|
||||
.map((i) => ({ label: `${i.displayName}(${i.publicIp || '无公网 IP'})`, value: i.id })),
|
||||
)
|
||||
|
||||
function openBind(row: ReservedIp) {
|
||||
bindTarget.value = row
|
||||
bindInstanceId.value = null
|
||||
showBind.value = true
|
||||
void instances.run()
|
||||
}
|
||||
|
||||
async function doBind() {
|
||||
if (!props.cfgId || !bindTarget.value || !bindInstanceId.value) return
|
||||
binding.value = true
|
||||
try {
|
||||
await assignReservedIp(props.cfgId, bindTarget.value.id, bindInstanceId.value, props.region)
|
||||
message.success('已绑定;实例原临时公网 IP 已释放')
|
||||
showBind.value = false
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '绑定失败')
|
||||
} finally {
|
||||
binding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(row: ReservedIp) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await assignReservedIp(props.cfgId, row.id, '', props.region)
|
||||
message.success('已解绑,实例暂无公网 IP')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '解绑失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: ReservedIp) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await deleteReservedIp(props.cfgId, row.id, props.region)
|
||||
message.success('保留 IP 已删除')
|
||||
void ips.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<ReservedIp>>(() => [
|
||||
{
|
||||
title: 'IP 地址',
|
||||
key: 'ipAddress',
|
||||
minWidth: 140,
|
||||
render: (r) => h('span', { class: 'mono' }, r.ipAddress || '分配中…'),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
key: 'displayName',
|
||||
minWidth: 130,
|
||||
render: (r) => h('span', { class: 'text-[13px]' }, r.displayName),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'assigned',
|
||||
minWidth: 110,
|
||||
render: (r) =>
|
||||
r.assignedInstanceId
|
||||
? h(StatusBadge, { kind: 'run', label: '已绑定', dot: false })
|
||||
: h(StatusBadge, { kind: 'check', label: '未绑定', dot: false }),
|
||||
},
|
||||
{
|
||||
title: '绑定实例',
|
||||
key: 'assignedInstanceName',
|
||||
minWidth: 150,
|
||||
render: (r) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-[13px] text-ink-2' },
|
||||
r.assignedInstanceName || (r.assignedInstanceId ? r.assignedInstanceId.slice(-8) : '—'),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'timeCreated',
|
||||
width: 150,
|
||||
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (r) =>
|
||||
h('div', { class: 'flex items-center gap-1' }, [
|
||||
r.assignedInstanceId
|
||||
? h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '解绑该保留 IP?',
|
||||
content: '解绑后实例将暂无公网 IP,直到重新绑定或分配临时 IP。',
|
||||
kind: 'plain',
|
||||
confirmText: '解绑',
|
||||
onConfirm: () => unbind(r),
|
||||
},
|
||||
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '解绑' }) },
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, onClick: () => openBind(r) },
|
||||
{ default: () => '绑定实例' },
|
||||
),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '删除该保留 IP?',
|
||||
content: '地址将归还 Oracle,无法找回。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => remove(r),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error', disabled: !!r.assignedInstanceId },
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
},
|
||||
),
|
||||
]),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div class="text-sm font-semibold">保留 IP</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
区域级固定公网 IP,可换绑实例或创建实例时选用
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :loading="creating" @click="create">创建保留 IP</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="ips.data.value ?? []"
|
||||
:loading="ips.loading.value"
|
||||
:scroll-x="820"
|
||||
:row-key="(r: ReservedIp) => r.id"
|
||||
/>
|
||||
|
||||
<NModal
|
||||
v-model:show="showBind"
|
||||
preset="card"
|
||||
title="绑定保留 IP 到实例"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-[13px] text-ink-2">
|
||||
将 <span class="mono">{{ bindTarget?.ipAddress }}</span> 绑定到实例主网卡;
|
||||
实例现有临时公网 IP 会被释放,公网地址变更为该保留 IP。
|
||||
</div>
|
||||
<NSelect
|
||||
v-model:value="bindInstanceId"
|
||||
:options="instanceOptions"
|
||||
:loading="instances.loading.value"
|
||||
placeholder="选择实例"
|
||||
filterable
|
||||
/>
|
||||
<div class="flex justify-end gap-2">
|
||||
<NButton size="small" @click="showBind = false">取消</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
:disabled="!bindInstanceId"
|
||||
:loading="binding"
|
||||
@click="doBind"
|
||||
>
|
||||
绑定
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
import { updateSecurityList } from '@/api/networks'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import type { SecurityList, SecurityRule } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
cfgId: number
|
||||
list: SecurityList
|
||||
region?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ updated: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
type Dir = 'ingress' | 'egress'
|
||||
|
||||
const PROTOCOLS = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: 'TCP', value: '6' },
|
||||
{ label: 'UDP', value: '17' },
|
||||
{ label: 'ICMP', value: '1' },
|
||||
{ label: 'ICMPv6', value: '58' },
|
||||
]
|
||||
|
||||
function protoLabel(p: string): string {
|
||||
return PROTOCOLS.find((o) => o.value === p)?.label ?? p
|
||||
}
|
||||
|
||||
function portText(r: SecurityRule): string {
|
||||
if (r.protocol !== '6' && r.protocol !== '17') return '—'
|
||||
if (!r.portMin) return '全部'
|
||||
return r.portMax && r.portMax !== r.portMin ? `${r.portMin}-${r.portMax}` : String(r.portMin)
|
||||
}
|
||||
|
||||
/** 编辑缓冲;idx 为 -1 表示新增行 */
|
||||
const editing = ref<{ dir: Dir; idx: number } | null>(null)
|
||||
const draft = reactive<SecurityRule>({ protocol: '6', isStateless: false })
|
||||
const saving = ref(false)
|
||||
|
||||
function rulesOf(dir: Dir): SecurityRule[] {
|
||||
return dir === 'ingress' ? (props.list.ingressRules ?? []) : (props.list.egressRules ?? [])
|
||||
}
|
||||
|
||||
function startEdit(dir: Dir, idx: number) {
|
||||
const src = idx >= 0 ? rulesOf(dir)[idx] : undefined
|
||||
Object.assign(draft, {
|
||||
protocol: src?.protocol ?? '6',
|
||||
source: src?.source,
|
||||
destination: src?.destination,
|
||||
isStateless: src?.isStateless ?? false,
|
||||
portMin: src?.portMin,
|
||||
portMax: src?.portMax,
|
||||
description: src?.description,
|
||||
})
|
||||
if (!src) {
|
||||
if (dir === 'ingress') draft.source = '0.0.0.0/0'
|
||||
else draft.destination = '0.0.0.0/0'
|
||||
}
|
||||
editing.value = { dir, idx }
|
||||
}
|
||||
|
||||
function buildDraft(dir: Dir): SecurityRule {
|
||||
const r: SecurityRule = { protocol: draft.protocol, isStateless: false }
|
||||
if (dir === 'ingress') r.source = draft.source?.trim()
|
||||
else r.destination = draft.destination?.trim()
|
||||
if (draft.protocol === '6' || draft.protocol === '17') {
|
||||
if (draft.portMin) {
|
||||
r.portMin = draft.portMin
|
||||
r.portMax = draft.portMax || draft.portMin
|
||||
}
|
||||
}
|
||||
if (draft.description?.trim()) r.description = draft.description.trim()
|
||||
return r
|
||||
}
|
||||
|
||||
/** 整表替换提交:规则字段一经提供即全量覆盖,两方向都要带上 */
|
||||
async function submit(ingress: SecurityRule[], egress: SecurityRule[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateSecurityList(props.cfgId, props.list.id, {
|
||||
region: props.region,
|
||||
ingressRules: ingress,
|
||||
egressRules: egress,
|
||||
})
|
||||
message.success('规则已保存')
|
||||
editing.value = null
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function saveEditing() {
|
||||
const ed = editing.value
|
||||
if (!ed) return
|
||||
const cidr = ed.dir === 'ingress' ? draft.source : draft.destination
|
||||
if (!cidr?.trim()) {
|
||||
message.warning('CIDR 不能为空')
|
||||
return
|
||||
}
|
||||
const next = rulesOf(ed.dir).slice()
|
||||
const rule = buildDraft(ed.dir)
|
||||
if (ed.idx >= 0) next[ed.idx] = rule
|
||||
else next.push(rule)
|
||||
const ing = ed.dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
||||
const eg = ed.dir === 'egress' ? next : (props.list.egressRules ?? [])
|
||||
void submit(ing, eg)
|
||||
}
|
||||
|
||||
function removeRule(dir: Dir, idx: number): Promise<void> {
|
||||
const next = rulesOf(dir).filter((_, i) => i !== idx)
|
||||
const ing = dir === 'ingress' ? next : (props.list.ingressRules ?? [])
|
||||
const eg = dir === 'egress' ? next : (props.list.egressRules ?? [])
|
||||
return submit(ing, eg)
|
||||
}
|
||||
|
||||
function isEditing(dir: Dir, idx: number): boolean {
|
||||
return editing.value?.dir === dir && editing.value.idx === idx
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 py-1">
|
||||
<div v-for="dir in ['ingress', 'egress'] as const" :key="dir">
|
||||
<div class="mb-1.5 text-[11.5px] font-semibold tracking-wide text-ink-3 uppercase">
|
||||
{{ dir === 'ingress' ? '入站规则' : '出站规则' }}
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-lg border border-line-soft bg-white">
|
||||
<table class="w-full border-collapse text-[12.5px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-28">协议</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-44">
|
||||
{{ dir === 'ingress' ? '源' : '目标' }}
|
||||
</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-36">端口</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold">描述</th>
|
||||
<th class="border-b border-line-soft px-3.5 py-1.5 text-right font-semibold w-24">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, idx) in rulesOf(dir)" :key="idx" :class="isEditing(dir, idx) ? 'bg-wash/60' : ''">
|
||||
<template v-if="isEditing(dir, idx)">
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NSelect
|
||||
v-model:value="draft.protocol"
|
||||
size="tiny"
|
||||
:options="PROTOCOLS"
|
||||
class="!w-24"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput
|
||||
v-if="dir === 'ingress'"
|
||||
v-model:value="draft.source"
|
||||
size="tiny"
|
||||
placeholder="0.0.0.0/0"
|
||||
/>
|
||||
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<div
|
||||
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMin"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="起"
|
||||
class="!w-16"
|
||||
/>
|
||||
<span class="text-ink-3">-</span>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMax"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="止"
|
||||
class="!w-16"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-ink-3">—</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
||||
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
||||
取消
|
||||
</NButton>
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">{{ protoLabel(r.protocol) }}</td>
|
||||
<td class="mono border-b border-line-soft px-3.5 py-1.5">
|
||||
{{ dir === 'ingress' ? r.source : r.destination }}
|
||||
</td>
|
||||
<td class="mono border-b border-line-soft px-3.5 py-1.5">{{ portText(r) }}</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-ink-2">{{ r.description || '—' }}</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" quaternary :disabled="!!editing" @click="startEdit(dir, idx)">
|
||||
编辑
|
||||
</NButton>
|
||||
<ConfirmPop
|
||||
title="删除该条规则?"
|
||||
:content="`${protoLabel(r.protocol)} ${dir === 'ingress' ? r.source : r.destination} ${portText(r)}`"
|
||||
:on-confirm="() => removeRule(dir, idx)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="error" class="ml-1" :disabled="!!editing">
|
||||
删除
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
<tr v-if="editing && editing.dir === dir && editing.idx === -1" class="bg-wash/60">
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NSelect
|
||||
v-model:value="draft.protocol"
|
||||
size="tiny"
|
||||
:options="PROTOCOLS"
|
||||
class="!w-24"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput
|
||||
v-if="dir === 'ingress'"
|
||||
v-model:value="draft.source"
|
||||
size="tiny"
|
||||
placeholder="0.0.0.0/0"
|
||||
/>
|
||||
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<div
|
||||
v-if="draft.protocol === '6' || draft.protocol === '17'"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMin"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="起"
|
||||
class="!w-16"
|
||||
/>
|
||||
<span class="text-ink-3">-</span>
|
||||
<NInputNumber
|
||||
v-model:value="draft.portMax"
|
||||
size="tiny"
|
||||
:show-button="false"
|
||||
placeholder="止"
|
||||
class="!w-16"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-ink-3">—</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5">
|
||||
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
|
||||
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
|
||||
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
|
||||
取消
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="5" class="px-3.5 py-1.5">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:disabled="!!editing"
|
||||
@click="startEdit(dir, -1)"
|
||||
>
|
||||
+ 添加{{ dir === 'ingress' ? '入站' : '出站' }}规则
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SelectOption } from 'naive-ui'
|
||||
import { h, type VNodeChild } from 'vue'
|
||||
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
|
||||
export interface ReservedIpOption extends SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 未绑定保留 IP → 下拉选项:label 仅 IP(回显紧凑不截断),名称在选项行内弱化展示 */
|
||||
export function freeReservedIpOptions(ips: ReservedIp[]): ReservedIpOption[] {
|
||||
return ips
|
||||
.filter((r) => !r.assignedInstanceId)
|
||||
.map((r) => ({ label: r.ipAddress || r.id.slice(-12), value: r.id, name: r.displayName }))
|
||||
}
|
||||
|
||||
/** NSelect renderLabel:IP 等宽字体 + 名称灰字截断;配合 consistent-menu-width=false 使用 */
|
||||
export function renderReservedIpLabel(option: SelectOption): VNodeChild {
|
||||
const o = option as ReservedIpOption
|
||||
return h('div', { class: 'flex min-w-0 items-baseline gap-2' }, [
|
||||
h('span', { class: 'mono text-[13px]' }, o.label),
|
||||
o.name ? h('span', { class: 'max-w-44 truncate text-xs text-ink-3' }, o.name) : null,
|
||||
])
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { extOf } from '@/components/objectstorage/codeHighlight'
|
||||
import { FILE_ICON_BODIES } from '@/components/objectstorage/fileIcons'
|
||||
|
||||
const props = defineProps<{ name?: string; folder?: boolean; size?: number }>()
|
||||
|
||||
/** 扩展名 → Catppuccin 图标名;不在表内用通用 file */
|
||||
const EXT_ICON: Record<string, string> = {
|
||||
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
|
||||
svg: 'image', bmp: 'image', ico: 'image', avif: 'image',
|
||||
json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml',
|
||||
toml: 'toml',
|
||||
ini: 'properties', conf: 'properties', cfg: 'properties', env: 'properties', properties: 'properties',
|
||||
py: 'python',
|
||||
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
|
||||
ts: 'typescript',
|
||||
md: 'markdown',
|
||||
sql: 'database', db: 'database', sqlite: 'database',
|
||||
sh: 'bash', bash: 'bash', zsh: 'bash',
|
||||
go: 'go',
|
||||
zip: 'zip', tar: 'zip', gz: 'zip', tgz: 'zip', '7z': 'zip', rar: 'zip', bz2: 'zip', xz: 'zip',
|
||||
pdf: 'pdf',
|
||||
mp4: 'video', mov: 'video', mkv: 'video', avi: 'video', webm: 'video',
|
||||
mp3: 'audio', wav: 'audio', flac: 'audio', ogg: 'audio', m4a: 'audio',
|
||||
html: 'html', htm: 'html',
|
||||
css: 'css', scss: 'css', less: 'css',
|
||||
txt: 'text',
|
||||
log: 'log',
|
||||
csv: 'csv', tsv: 'csv',
|
||||
xml: 'xml',
|
||||
bin: 'binary', exe: 'binary', dmg: 'binary', iso: 'binary', img: 'binary', qcow2: 'binary',
|
||||
doc: 'ms-word', docx: 'ms-word',
|
||||
xls: 'ms-excel', xlsx: 'ms-excel',
|
||||
ppt: 'ms-powerpoint', pptx: 'ms-powerpoint',
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
if (props.folder) return FILE_ICON_BODIES.folder
|
||||
const key = EXT_ICON[extOf(props.name ?? '')] ?? 'file'
|
||||
return FILE_ICON_BODIES[key] ?? FILE_ICON_BODIES.file
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- eslint-disable vue/no-v-html -- 图标 body 为构建期静态常量(Catppuccin Icons),非用户输入 -->
|
||||
<svg
|
||||
:width="size ?? 16"
|
||||
:height="size ?? 16"
|
||||
viewBox="0 0 16 16"
|
||||
class="inline-block flex-none align-[-3px]"
|
||||
aria-hidden="true"
|
||||
v-html="body"
|
||||
></svg>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
</template>
|
||||
@@ -0,0 +1,950 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NDropdown,
|
||||
NInput,
|
||||
NRadio,
|
||||
NRadioGroup,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
createPar,
|
||||
deleteObject,
|
||||
deletePar,
|
||||
listObjects,
|
||||
restoreObject,
|
||||
uploadViaPar,
|
||||
} from '@/api/objectstorage'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import FileIcon from '@/components/objectstorage/FileIcon.vue'
|
||||
import ObjectViewerModal from '@/components/objectstorage/ObjectViewerModal.vue'
|
||||
import ParPanel from '@/components/objectstorage/ParPanel.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import type { Bucket, ObjectSummary } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
bucket: string
|
||||
/** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
|
||||
info?: Bucket
|
||||
}>()
|
||||
const emit = defineEmits<{ back: []; settings: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** hero 与 Tab:对象浏览 / 分享链接(PAR) */
|
||||
const tab = ref<'objects' | 'par'>('objects')
|
||||
/** 移动端 hero meta 默认收起,与租户/实例/VCN 详情一致 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const prefix = ref('')
|
||||
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
|
||||
const filterInput = ref('')
|
||||
/** 上页游标栈:OCI 只回传 next 游标,上一页靠历史栈回放 */
|
||||
const cursorStack = ref<string[]>([])
|
||||
const cursor = ref('')
|
||||
|
||||
const result = useAsync(async () => {
|
||||
if (!props.cfgId) return { objects: [], prefixes: [], nextStartWith: '' }
|
||||
return listObjects(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
prefix: prefix.value,
|
||||
startWith: cursor.value || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
}, false)
|
||||
|
||||
// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置;
|
||||
// 层内进目录/翻页只重查列表
|
||||
watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope())
|
||||
watch([prefix, cursor], () => void result.run(), { immediate: true })
|
||||
|
||||
/** 作用域变化后重置路径、游标、选中项与弹窗再重查:旧列表配新作用域时,
|
||||
* 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */
|
||||
function resetScope() {
|
||||
prefix.value = ''
|
||||
filterInput.value = ''
|
||||
cursorStack.value = []
|
||||
cursor.value = ''
|
||||
checkedKeys.value = []
|
||||
showPar.value = false
|
||||
showViewer.value = false
|
||||
void result.run()
|
||||
}
|
||||
|
||||
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
|
||||
const POLL_MS = 30_000
|
||||
let pollTimer = 0
|
||||
watch(
|
||||
() => (result.data.value?.objects ?? []).some((o) => o.archivalState === 'Restoring'),
|
||||
(has) => {
|
||||
window.clearInterval(pollTimer)
|
||||
if (has) pollTimer = window.setInterval(() => void result.run({ silent: true }), POLL_MS)
|
||||
},
|
||||
)
|
||||
onBeforeUnmount(() => window.clearInterval(pollTimer))
|
||||
|
||||
function enterFolder(p: string) {
|
||||
cursorStack.value = []
|
||||
cursor.value = ''
|
||||
prefix.value = p
|
||||
filterInput.value = ''
|
||||
}
|
||||
|
||||
/** 面包屑段:桶根 + prefix 逐级 */
|
||||
const crumbs = computed(() => {
|
||||
const parts = prefix.value.split('/').filter(Boolean)
|
||||
return parts.map((seg, i) => ({ label: `${seg}/`, prefix: parts.slice(0, i + 1).join('/') + '/' }))
|
||||
})
|
||||
|
||||
/** 层级过深时仅展示末 2 段,其余收进「…」下拉,避免面包屑占满整行 */
|
||||
const visibleCrumbs = computed(() =>
|
||||
crumbs.value.length > 3 ? crumbs.value.slice(-2) : crumbs.value,
|
||||
)
|
||||
const foldedOptions = computed(() =>
|
||||
crumbs.value.length > 3
|
||||
? crumbs.value.slice(0, -2).map((c) => ({ label: c.prefix, key: c.prefix }))
|
||||
: [],
|
||||
)
|
||||
|
||||
function nextPage() {
|
||||
const next = result.data.value?.nextStartWith
|
||||
if (!next) return
|
||||
cursorStack.value.push(cursor.value)
|
||||
cursor.value = next
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
cursor.value = cursorStack.value.pop() ?? ''
|
||||
}
|
||||
|
||||
// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
|
||||
interface TransferTask {
|
||||
id: number
|
||||
kind: 'up' | 'down'
|
||||
name: string
|
||||
percent: number
|
||||
status: 'active' | 'done' | 'error'
|
||||
/** 附注:如「已交由浏览器下载」(无进度) */
|
||||
note?: string
|
||||
error?: string
|
||||
abort: () => void
|
||||
retry: () => void
|
||||
}
|
||||
const transfers = ref<TransferTask[]>([])
|
||||
const transfersCollapsed = ref(false)
|
||||
let taskSeq = 0
|
||||
|
||||
const activeCount = computed(() => transfers.value.filter((t) => t.status === 'active').length)
|
||||
const doneCount = computed(() => transfers.value.filter((t) => t.status === 'done').length)
|
||||
|
||||
function newTask(kind: 'up' | 'down', name: string, retry: () => void): TransferTask {
|
||||
const task = reactive<TransferTask>({
|
||||
id: ++taskSeq, kind, name, percent: 0, status: 'active', abort: () => {}, retry,
|
||||
})
|
||||
transfers.value.push(task)
|
||||
transfersCollapsed.value = false
|
||||
return task
|
||||
}
|
||||
|
||||
function removeTask(task: TransferTask) {
|
||||
task.abort()
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
}
|
||||
|
||||
function retryTask(task: TransferTask) {
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
task.retry()
|
||||
}
|
||||
|
||||
function clearDone() {
|
||||
transfers.value = transfers.value.filter((t) => t.status !== 'done')
|
||||
}
|
||||
|
||||
// ---- 上传:PAR 直传,逐文件进度 ----
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickFiles() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFilesPicked(e: Event) {
|
||||
const files = [...((e.target as HTMLInputElement).files ?? [])]
|
||||
;(e.target as HTMLInputElement).value = ''
|
||||
await Promise.all(files.map((f) => uploadOne(f)))
|
||||
void result.run({ silent: true })
|
||||
}
|
||||
|
||||
/** objectName 进闭包:重试时不受当前浏览层级变化影响 */
|
||||
function uploadOne(file: File, objectName = prefix.value + file.name) {
|
||||
const task = newTask('up', file.name, () => uploadOne(file, objectName))
|
||||
return doUpload(task, objectName, file)
|
||||
}
|
||||
|
||||
async function doUpload(task: TransferTask, objectName: string, file: File) {
|
||||
if (!props.cfgId) return
|
||||
let parId = ''
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName,
|
||||
accessType: 'ObjectWrite',
|
||||
expiresHours: 1,
|
||||
})
|
||||
parId = par.id
|
||||
const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
|
||||
task.abort = abort
|
||||
await promise
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
} catch (err) {
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '上传失败'
|
||||
} finally {
|
||||
if (parId) void cleanupPar(parId)
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传用的一次性写链接用后即删,不留在分享链接列表;删除失败静默(1 小时自然过期) */
|
||||
async function cleanupPar(parId: string) {
|
||||
try {
|
||||
await deletePar(props.cfgId!, props.bucket, parId, props.region)
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
} finally {
|
||||
parPanel.value?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
|
||||
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
|
||||
|
||||
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
|
||||
function willBrowserHandle(size: number): boolean {
|
||||
return !size || size > DOWN_STREAM_LIMIT
|
||||
}
|
||||
|
||||
function download(name: string, size?: number) {
|
||||
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
|
||||
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
|
||||
// 预开窗口必须在点击手势内同步进行: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, preWin: Window | null) {
|
||||
if (!props.cfgId) {
|
||||
closeIfBlank(preWin)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 1,
|
||||
})
|
||||
const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
|
||||
if (cleanable) void cleanupPar(par.id)
|
||||
else parPanel.value?.refresh()
|
||||
} catch (err) {
|
||||
// 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页
|
||||
closeIfBlank(preWin)
|
||||
if ((err as DOMException)?.name === 'AbortError') return
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '下载失败'
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
|
||||
function closeIfBlank(preWin: Window | null) {
|
||||
if (preWin && !preWin.closed) preWin.close()
|
||||
}
|
||||
|
||||
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
|
||||
async function transferDown(
|
||||
task: TransferTask,
|
||||
url: string,
|
||||
size: number,
|
||||
preWin: Window | null,
|
||||
): Promise<boolean> {
|
||||
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
|
||||
try {
|
||||
const blob = await fetchWithProgress(task, url, size)
|
||||
saveBlob(blob, task.name)
|
||||
return true
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') throw err
|
||||
// 流式失败回退浏览器接管:此处已脱离手势无预开窗口,被拦会如实报错
|
||||
return browserOpen(task, url, null)
|
||||
}
|
||||
}
|
||||
|
||||
/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
|
||||
function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean {
|
||||
const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank')
|
||||
if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试')
|
||||
if (win === preWin) win.location.replace(url)
|
||||
task.note = '已交由浏览器下载'
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise<Blob> {
|
||||
const ctrl = new AbortController()
|
||||
task.abort = () => ctrl.abort()
|
||||
const resp = await fetch(url, { signal: ctrl.signal })
|
||||
if (!resp.ok || !resp.body) throw new Error(`下载失败(HTTP ${resp.status})`)
|
||||
const total = Number(resp.headers.get('Content-Length')) || size
|
||||
const reader = resp.body.getReader()
|
||||
const chunks: BlobPart[] = []
|
||||
let loaded = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
loaded += value.byteLength
|
||||
task.percent = Math.min(99, Math.round((loaded / total) * 100))
|
||||
}
|
||||
return new Blob(chunks)
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
|
||||
}
|
||||
|
||||
// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
|
||||
const showNewFolder = ref(false)
|
||||
const folderName = ref('')
|
||||
const creatingFolder = ref(false)
|
||||
|
||||
async function doCreateFolder() {
|
||||
if (!props.cfgId || !folderName.value.trim()) return
|
||||
creatingFolder.value = true
|
||||
let parId = ''
|
||||
try {
|
||||
const name = prefix.value + folderName.value.trim().replace(/\/+$/, '') + '/'
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectWrite',
|
||||
expiresHours: 1,
|
||||
})
|
||||
parId = par.id
|
||||
await uploadViaPar(par.fullUrl!, new File([], name), () => {}).promise
|
||||
message.success('文件夹已创建')
|
||||
showNewFolder.value = false
|
||||
folderName.value = ''
|
||||
void result.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
} finally {
|
||||
if (parId) void cleanupPar(parId)
|
||||
creatingFolder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 行操作 ----
|
||||
async function removeObject(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await deleteObject(props.cfgId, props.bucket, name, props.region)
|
||||
message.success('对象已删除')
|
||||
checkedKeys.value = checkedKeys.value.filter((k) => k !== name)
|
||||
void result.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await restoreObject(props.cfgId, props.bucket, name, props.region)
|
||||
message.success('取回已提交,约 1 小时后可下载 (24 小时有效)')
|
||||
// 立刻刷出 Restoring 态,触发后台轮询
|
||||
void result.run({ silent: true })
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '取回失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
|
||||
const showPar = ref(false)
|
||||
const parBusy = ref(false)
|
||||
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
|
||||
objectName: '',
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 24,
|
||||
})
|
||||
const parUrl = ref('')
|
||||
const parIsBucket = computed(() => !parForm.objectName)
|
||||
const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24
|
||||
const parExpiresValid = computed(() => {
|
||||
const hours = parForm.expiresHours
|
||||
return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS
|
||||
})
|
||||
/** 有效期快捷项;也可直接输入 1 至 876000 小时 */
|
||||
const QUICK_HOURS = [
|
||||
{ label: '1 小时', value: 1 },
|
||||
{ label: '1 天', value: 24 },
|
||||
{ label: '7 天', value: 168 },
|
||||
{ label: '30 天', value: 720 },
|
||||
{ label: '1 年', value: 8760 },
|
||||
{ label: '10 年', value: 87600 },
|
||||
]
|
||||
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
|
||||
|
||||
function openParModal(objectName: string) {
|
||||
Object.assign(parForm, {
|
||||
objectName,
|
||||
accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
|
||||
expiresHours: 24,
|
||||
})
|
||||
parUrl.value = ''
|
||||
showPar.value = true
|
||||
}
|
||||
|
||||
async function doCreatePar() {
|
||||
const expiresHours = parForm.expiresHours
|
||||
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
|
||||
parBusy.value = true
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: parForm.objectName,
|
||||
accessType: parForm.accessType,
|
||||
expiresHours,
|
||||
})
|
||||
parUrl.value = par.fullUrl ?? ''
|
||||
parPanel.value?.refresh()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '签发失败')
|
||||
} finally {
|
||||
parBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyParUrl() {
|
||||
await navigator.clipboard.writeText(parUrl.value)
|
||||
message.success('链接已复制')
|
||||
}
|
||||
|
||||
// ---- 批量选择 ----
|
||||
const checkedKeys = ref<string[]>([])
|
||||
|
||||
async function batchDelete() {
|
||||
for (const name of [...checkedKeys.value]) await removeObject(name)
|
||||
}
|
||||
|
||||
async function batchDownload() {
|
||||
for (const name of [...checkedKeys.value]) {
|
||||
await download(name)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 对象查看器:点击对象名打开,预览/编辑合一 ----
|
||||
const viewerObject = ref('')
|
||||
const showViewer = ref(false)
|
||||
|
||||
function openViewer(name: string) {
|
||||
viewerObject.value = name
|
||||
showViewer.value = true
|
||||
}
|
||||
|
||||
interface Row {
|
||||
key: string
|
||||
isFolder: boolean
|
||||
obj?: ObjectSummary
|
||||
}
|
||||
|
||||
const rows = computed<Row[]>(() => {
|
||||
const data = result.data.value
|
||||
if (!data) return []
|
||||
const folders = (data.prefixes ?? []).map((p) => ({ key: p, isFolder: true }))
|
||||
// 文件夹占位对象(名字等于当前前缀自身)不展示
|
||||
const files = (data.objects ?? [])
|
||||
.filter((o) => o.name !== prefix.value)
|
||||
.map((o) => ({ key: o.name, isFolder: false, obj: o }))
|
||||
const all = [...folders, ...files]
|
||||
const q = filterInput.value.trim().toLowerCase()
|
||||
if (!q) return all
|
||||
return all.filter((r) => r.key.slice(prefix.value.length).toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const columns = computed<DataTableColumns<Row>>(() => [
|
||||
{ type: 'selection', disabled: (row) => row.isFolder },
|
||||
{
|
||||
title: '名称',
|
||||
key: 'name',
|
||||
minWidth: 260,
|
||||
// 超长对象名截断,悬停 tooltip 看全名
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) =>
|
||||
row.isFolder
|
||||
? h(
|
||||
'span',
|
||||
{
|
||||
class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] font-medium hover:text-accent',
|
||||
onClick: () => enterFolder(row.key),
|
||||
},
|
||||
[h(FileIcon, { folder: true }), row.key.slice(prefix.value.length)],
|
||||
)
|
||||
: h(
|
||||
'span',
|
||||
{
|
||||
class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] hover:text-accent',
|
||||
onClick: () => openViewer(row.key),
|
||||
},
|
||||
[h(FileIcon, { name: row.key }), row.key.slice(prefix.value.length)],
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
key: 'size',
|
||||
width: 110,
|
||||
render: (row) => h('span', { class: 'mono text-[12.5px]' }, row.isFolder ? '—' : fmtBytes(row.obj!.size)),
|
||||
},
|
||||
{
|
||||
title: '存储层',
|
||||
key: 'tier',
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
if (row.isFolder) return h('span', { class: 'text-ink-3' }, '—')
|
||||
const o = row.obj!
|
||||
if (o.storageTier === 'Archive')
|
||||
return h(
|
||||
'span',
|
||||
{ class: 'rounded-full bg-warn/14 px-2 py-0.5 text-[11.5px] font-medium text-warn' },
|
||||
o.archivalState === 'Restored'
|
||||
? '已取回'
|
||||
: o.archivalState === 'Restoring'
|
||||
? '取回中'
|
||||
: 'Archive',
|
||||
)
|
||||
return h('span', { class: 'text-[13px]' }, o.storageTier || 'Standard')
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '修改时间',
|
||||
key: 'time',
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
h(
|
||||
'span',
|
||||
{ class: 'text-[13px] text-ink-2' },
|
||||
row.isFolder ? '—' : fmtTime(row.obj!.timeModified),
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: (row) => {
|
||||
if (row.isFolder) return null
|
||||
const o = row.obj!
|
||||
const restoring = o.storageTier === 'Archive' && o.archivalState === 'Restoring'
|
||||
const archived =
|
||||
o.storageTier === 'Archive' && o.archivalState !== 'Restored' && !restoring
|
||||
return h('div', { class: 'flex items-center gap-1' }, [
|
||||
restoring
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, disabled: true },
|
||||
{ default: () => '取回中' },
|
||||
)
|
||||
: archived
|
||||
? h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '取回 Archive 对象?',
|
||||
content: '约 1 小时后可下载 (24 小时有效)。',
|
||||
kind: 'plain',
|
||||
confirmText: '取回',
|
||||
onConfirm: () => restore(o.name),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', quaternary: true }, { default: () => '恢复' }),
|
||||
},
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, onClick: () => download(o.name, o.size) },
|
||||
{ default: () => '下载' },
|
||||
),
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '删除该对象?',
|
||||
content: '未开版本控制时不可恢复。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => removeObject(o.name),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error' },
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
},
|
||||
),
|
||||
])
|
||||
},
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- hero:与租户/VCN 详情同构——返回链 + 标题行(徽标/操作)+ meta 栅格(移动可折叠) -->
|
||||
<div class="panel px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-2 inline-flex cursor-pointer items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← 返回存储桶列表
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title min-w-0 truncate">{{ bucket }}</h1>
|
||||
<StatusBadge
|
||||
v-if="info"
|
||||
:kind="info.visibility === 'ObjectRead' ? 'prov' : 'check'"
|
||||
:label="info.visibility === 'ObjectRead' ? '公共读' : '私有'"
|
||||
:dot="false"
|
||||
/>
|
||||
<StatusBadge v-if="info?.versioningOn" kind="run" label="版本控制" :dot="false" />
|
||||
<div class="flex-1" />
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="medium" @click="openParModal('')">分享桶</NButton>
|
||||
<NButton size="medium" @click="emit('settings')">桶设置</NButton>
|
||||
<NButton size="medium" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">对象数</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info?.approximateCount ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">已用容量</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info ? fmtBytes(info.approximateSize) : '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">存储层</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.storageTier ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">创建时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="objects" tab="对象" />
|
||||
<NTabPane name="par" tab="分享链接" />
|
||||
</NTabs>
|
||||
|
||||
<div v-show="tab === 'objects'" class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3 max-md:px-3.5">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
|
||||
<span
|
||||
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
|
||||
:title="bucket"
|
||||
@click="enterFolder('')"
|
||||
>
|
||||
{{ bucket }}
|
||||
</span>
|
||||
<template v-if="foldedOptions.length">
|
||||
<span>/</span>
|
||||
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
|
||||
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
|
||||
</NDropdown>
|
||||
</template>
|
||||
<template v-for="c in visibleCrumbs" :key="c.prefix">
|
||||
<span>/</span>
|
||||
<span
|
||||
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
|
||||
:title="c.label"
|
||||
@click="enterFolder(c.prefix)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NInput
|
||||
v-model:value="filterInput"
|
||||
size="small"
|
||||
placeholder="搜索本层对象…"
|
||||
clearable
|
||||
class="!w-52 max-md:!w-auto max-md:flex-1"
|
||||
/>
|
||||
<NButton size="small" @click="showNewFolder = true">新建文件夹</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="checkedKeys.length"
|
||||
class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
|
||||
>
|
||||
<span class="font-medium">已选 {{ checkedKeys.length }} 项</span>
|
||||
<NButton size="tiny" quaternary @click="batchDownload">逐个下载</NButton>
|
||||
<ConfirmPop
|
||||
:title="`删除选中的 ${checkedKeys.length} 个对象?`"
|
||||
content="未开版本控制时不可恢复。"
|
||||
confirm-text="删除"
|
||||
:on-confirm="batchDelete"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<span class="ml-auto cursor-pointer text-ink-3 hover:text-ink" @click="checkedKeys = []">
|
||||
清除选择
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedKeys"
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="result.loading.value"
|
||||
:scroll-x="880"
|
||||
:row-key="(r: Row) => r.key"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
|
||||
<span class="flex flex-none items-center gap-1.5">
|
||||
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="prevPage">
|
||||
上一页
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!result.data.value?.nextStartWith" @click="nextPage">
|
||||
下一页
|
||||
</NButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ParPanel v-show="tab === 'par'" ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
|
||||
|
||||
<!-- 传输列表浮层:上传主色 / 下载 info 色,失败红字可重试,可收起 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="transfers.length"
|
||||
class="fixed right-5 bottom-5 z-40 w-80 max-w-[calc(100vw-24px)] max-md:right-3 max-md:bottom-[calc(64px_+_env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-card shadow-[0_10px_32px_-8px_rgba(0,0,0,0.22)]">
|
||||
<div
|
||||
class="flex items-center gap-2 bg-wash px-3.5 py-2 text-[12.5px]"
|
||||
:class="transfersCollapsed ? '' : 'border-b border-line-soft'"
|
||||
>
|
||||
<span class="font-medium">传输列表</span>
|
||||
<span v-if="activeCount" class="text-ink-3">{{ activeCount }} 进行中</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
v-if="doneCount"
|
||||
type="button"
|
||||
class="cursor-pointer rounded px-1.5 py-0.5 text-xs text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
@click="clearDone"
|
||||
>
|
||||
清除已完成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded p-1 text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
:title="transfersCollapsed ? '展开' : '收起'"
|
||||
@click="transfersCollapsed = !transfersCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform"
|
||||
:class="transfersCollapsed ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!transfersCollapsed" class="max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="t in transfers"
|
||||
:key="t.id"
|
||||
class="flex items-center gap-2.5 border-b border-line-soft px-3.5 py-2 text-[12.5px] last:border-b-0"
|
||||
>
|
||||
<span
|
||||
class="flex-none font-semibold"
|
||||
:class="t.status === 'error' ? 'text-err' : t.kind === 'up' ? 'text-accent' : 'text-info'"
|
||||
>
|
||||
{{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="mono truncate">{{ t.name }}</span>
|
||||
<span v-if="t.status === 'active'" class="mono flex-none text-xs text-ink-3">
|
||||
{{ t.percent }}%
|
||||
</span>
|
||||
<span v-else-if="t.status === 'done'" class="flex-none text-xs text-ok">完成</span>
|
||||
</div>
|
||||
<div v-if="t.status === 'active'" class="mt-1 h-1 overflow-hidden rounded-full bg-line-soft">
|
||||
<span
|
||||
class="block h-full rounded-full"
|
||||
:class="t.kind === 'up' ? 'bg-accent' : 'bg-info'"
|
||||
:style="{ width: `${t.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="t.status === 'error'" class="mt-0.5 flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 truncate text-err" :title="t.error">{{ t.error }}</span>
|
||||
<span class="flex-none cursor-pointer font-medium text-accent" @click="retryTask(t)">
|
||||
重试
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="t.note" class="mt-0.5 text-xs text-ink-3">{{ t.note }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer text-ink-3 hover:text-ink"
|
||||
:title="t.status === 'active' ? '取消' : '移除'"
|
||||
@click="removeTask(t)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ObjectViewerModal
|
||||
v-model:show="showViewer"
|
||||
:cfg-id="cfgId"
|
||||
:region="region"
|
||||
:bucket="bucket"
|
||||
:object="viewerObject"
|
||||
:versioning-on="info?.versioningOn"
|
||||
:visibility="info?.visibility"
|
||||
:namespace="info?.namespace"
|
||||
@download="download"
|
||||
@share="openParModal"
|
||||
@changed="() => void result.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<FormModal
|
||||
:show="showNewFolder"
|
||||
title="新建文件夹"
|
||||
:width="380"
|
||||
:submitting="creatingFolder"
|
||||
:submit-disabled="!folderName.trim()"
|
||||
submit-text="创建"
|
||||
@update:show="showNewFolder = $event"
|
||||
@submit="doCreateFolder"
|
||||
>
|
||||
<FormField label="文件夹名" hint="以 0 字节对象实现虚拟层级">
|
||||
<NInput v-model:value="folderName" placeholder="backup" class="mono" />
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
:show="showPar"
|
||||
:title="parIsBucket ? '分享桶' : '分享对象'"
|
||||
:width="460"
|
||||
:submitting="parBusy"
|
||||
:submit-disabled="!!parUrl || !parExpiresValid"
|
||||
submit-text="签发"
|
||||
@update:show="showPar = $event"
|
||||
@submit="doCreatePar"
|
||||
>
|
||||
<FormField :label="parIsBucket ? '范围' : '对象'">
|
||||
<span class="mono text-[12.5px]">{{ parForm.objectName || '整桶对象(桶级链接)' }}</span>
|
||||
</FormField>
|
||||
<FormField label="权限">
|
||||
<NRadioGroup v-model:value="parForm.accessType">
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectRead' : 'ObjectRead'">只读</NRadio>
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
|
||||
</NRadioGroup>
|
||||
</FormField>
|
||||
<FormField label="有效期" hint="最长 100 年(876000 小时)">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<AppInputNumber
|
||||
v-model:value="parForm.expiresHours"
|
||||
:min="1"
|
||||
:max="PAR_MAX_EXPIRES_HOURS"
|
||||
:precision="0"
|
||||
size="small"
|
||||
class="!w-24"
|
||||
/>
|
||||
<span class="text-[12.5px] text-ink-2">小时</span>
|
||||
<ChoiceChip
|
||||
v-for="q in QUICK_HOURS"
|
||||
:key="q.value"
|
||||
:active="parForm.expiresHours === q.value"
|
||||
@click="parForm.expiresHours = q.value"
|
||||
>
|
||||
{{ q.label }}
|
||||
</ChoiceChip>
|
||||
</div>
|
||||
</FormField>
|
||||
<div
|
||||
v-if="parUrl"
|
||||
class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
|
||||
>
|
||||
<span class="mono min-w-0 flex-1 truncate text-xs">{{ parUrl }}</span>
|
||||
<NButton size="tiny" quaternary @click="copyParUrl">复制</NButton>
|
||||
</div>
|
||||
<div v-if="parUrl" class="mt-1 text-xs text-warn">
|
||||
链接仅本次展示,关闭后无法再次获取,请立即保存
|
||||
</div>
|
||||
</FormModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,535 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal, NSpin } from 'naive-ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import {
|
||||
deleteObject,
|
||||
getObjectContent,
|
||||
getObjectDetail,
|
||||
putObjectContent,
|
||||
renameObject,
|
||||
} from '@/api/objectstorage'
|
||||
import { ApiError } from '@/api/request'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import { extOf, previewKindOf } from '@/components/objectstorage/codeHighlight'
|
||||
import { canFormat, formatSource } from '@/components/objectstorage/format'
|
||||
import FileIcon from '@/components/objectstorage/FileIcon.vue'
|
||||
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
|
||||
import TextEditPane from '@/components/objectstorage/viewer/TextEditPane.vue'
|
||||
import TextPreviewPane from '@/components/objectstorage/viewer/TextPreviewPane.vue'
|
||||
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
|
||||
/** 对象查看器:预览与编辑合一,左内容区 + 右信息栏。
|
||||
* 内容经面板中转直读直写(不签发 PAR);保存以 If-Match 做并发保护,
|
||||
* 412 冲突与无版本控制桶走二段确认。 */
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
bucket: string
|
||||
object: string
|
||||
/** 桶版本控制状态;未知按未开启保守处理 */
|
||||
versioningOn?: boolean
|
||||
/** 桶可见性(NoPublicAccess / ObjectRead);公共读时「复制 URI」变「复制 URL」 */
|
||||
visibility?: string
|
||||
/** 桶 namespace,拼公网对象 URL 用 */
|
||||
namespace?: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:show': [boolean]
|
||||
download: [string]
|
||||
share: [string]
|
||||
/** 保存/重命名/删除后,列表需要刷新 */
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** 移动端全屏(复用 main.css 的 .form-modal-mobile 全局全屏规则) */
|
||||
const isMobile = useIsMobile()
|
||||
const modalStyle = computed(() =>
|
||||
isMobile.value
|
||||
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
|
||||
: { width: '1080px', maxWidth: 'calc(100vw - 24px)' },
|
||||
)
|
||||
|
||||
/** 文本预览/编辑体积上限;更大的文本请下载查看 */
|
||||
const TEXT_MAX = 1024 * 1024
|
||||
/** 图片与 office/pdf 的中转上限(与后端 20MB 限制一致) */
|
||||
const BINARY_MAX = 20 * 1024 * 1024
|
||||
/** pre 渲染字符上限,超出截断展示 */
|
||||
const TEXT_SHOW_MAX = 256 * 1024
|
||||
|
||||
const detail = useAsync(async () => {
|
||||
if (!props.cfgId || !props.object) return null
|
||||
return getObjectDetail(props.cfgId, props.bucket, props.object, props.region)
|
||||
}, false)
|
||||
|
||||
const ext = computed(() => extOf(props.object))
|
||||
const kind = computed(() => previewKindOf(props.object))
|
||||
const archived = computed(() => {
|
||||
const d = detail.data.value
|
||||
return d?.storageTier === 'Archive' && d.archivalState !== 'Restored'
|
||||
})
|
||||
const size = computed(() => detail.data.value?.size ?? 0)
|
||||
const tooBig = computed(() =>
|
||||
kind.value === 'text' ? size.value > TEXT_MAX : size.value > BINARY_MAX,
|
||||
)
|
||||
const canEdit = computed(() => kind.value === 'text' && !archived.value && !tooBig.value)
|
||||
const officeKind = computed(() => {
|
||||
const k = kind.value
|
||||
return k === 'docx' || k === 'xlsx' || k === 'pdf' || k === 'pptx' ? k : null
|
||||
})
|
||||
const blockedReason = computed(() => {
|
||||
if (kind.value === 'none') return '该格式暂不支持预览,请下载查看'
|
||||
if (archived.value) return 'Archive 对象需先恢复才能预览'
|
||||
if (tooBig.value)
|
||||
return kind.value === 'text' ? '文本超过 1 MB,请下载查看' : '文件超过 20 MB,请下载查看'
|
||||
return ''
|
||||
})
|
||||
|
||||
// ---- 内容状态 ----
|
||||
const mode = ref<'preview' | 'edit'>('preview')
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
/** 文本完整内容(编辑基线,不做美化改写) */
|
||||
const fullText = ref('')
|
||||
/** 展示文本(json 美化、超长截断) */
|
||||
const previewText = ref('')
|
||||
const truncated = ref(false)
|
||||
const imageUrl = ref('')
|
||||
const officeData = ref<ArrayBuffer | null>(null)
|
||||
const officeError = ref('')
|
||||
const contentEtag = ref('')
|
||||
const contentType = ref('')
|
||||
|
||||
watch(
|
||||
() => [props.show, props.object],
|
||||
([show]) => {
|
||||
if (!show) {
|
||||
revokeImage()
|
||||
return
|
||||
}
|
||||
reset()
|
||||
const seq = ++contentSeq
|
||||
void detail.run().then(() => {
|
||||
// detail 是共享 useAsync:换对象后旧 then 链仍会执行,按代次拦下
|
||||
if (seq === contentSeq && !blockedReason.value) void loadContent()
|
||||
})
|
||||
},
|
||||
)
|
||||
onBeforeUnmount(revokeImage)
|
||||
|
||||
function reset() {
|
||||
mode.value = 'preview'
|
||||
// 立即清掉上一对象的元数据,右栏转入加载态,避免残留展示
|
||||
detail.data.value = null
|
||||
loading.value = false
|
||||
loadError.value = ''
|
||||
fullText.value = ''
|
||||
previewText.value = ''
|
||||
truncated.value = false
|
||||
revokeImage()
|
||||
officeData.value = null
|
||||
officeError.value = ''
|
||||
contentEtag.value = ''
|
||||
contentType.value = ''
|
||||
renaming.value = false
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
function revokeImage() {
|
||||
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
|
||||
imageUrl.value = ''
|
||||
}
|
||||
|
||||
// 内容加载代次:换对象/关闭即自增,旧对象的迟到响应按代次丢弃——
|
||||
// 否则 A 的正文与 ETag 会顶着 B 的标题展示,编辑保存还会拿错基线
|
||||
let contentSeq = 0
|
||||
|
||||
async function loadContent() {
|
||||
if (!props.cfgId || loading.value) return
|
||||
const seq = ++contentSeq
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
try {
|
||||
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
|
||||
if (seq !== contentSeq) return
|
||||
contentEtag.value = c.etag
|
||||
contentType.value = c.contentType
|
||||
await applyContent(c.blob)
|
||||
} catch (e) {
|
||||
if (seq === contentSeq) loadError.value = e instanceof Error ? e.message : '内容加载失败'
|
||||
} finally {
|
||||
if (seq === contentSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function applyContent(blob: Blob) {
|
||||
if (kind.value === 'image') {
|
||||
imageUrl.value = URL.createObjectURL(blob)
|
||||
return
|
||||
}
|
||||
if (kind.value === 'text') {
|
||||
setText(await blob.text())
|
||||
return
|
||||
}
|
||||
officeData.value = await blob.arrayBuffer()
|
||||
}
|
||||
|
||||
function setText(raw: string) {
|
||||
fullText.value = raw
|
||||
const pretty = ext.value === 'json' ? prettyJson(raw) : raw
|
||||
truncated.value = pretty.length > TEXT_SHOW_MAX
|
||||
previewText.value = truncated.value ? pretty.slice(0, TEXT_SHOW_MAX) : pretty
|
||||
}
|
||||
|
||||
/** JSON 美化失败时原样展示 */
|
||||
function prettyJson(s: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2)
|
||||
} catch {
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 编辑 ----
|
||||
const editText = ref('')
|
||||
const saving = ref(false)
|
||||
/** 非空时保存进入二段确认态,内容为需用户确认的原因 */
|
||||
const pendingConfirm = ref('')
|
||||
/** 412 冲突确认后置真:下次保存不带 If-Match,无条件覆盖 */
|
||||
const overwrite = ref(false)
|
||||
const editByteSize = computed(() => new Blob([editText.value]).size)
|
||||
|
||||
function resetEdit() {
|
||||
editText.value = ''
|
||||
saving.value = false
|
||||
pendingConfirm.value = ''
|
||||
overwrite.value = false
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
editText.value = fullText.value
|
||||
pendingConfirm.value = ''
|
||||
overwrite.value = false
|
||||
mode.value = 'edit'
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
mode.value = 'preview'
|
||||
resetEdit()
|
||||
}
|
||||
|
||||
const canBeautify = computed(() => canFormat(ext.value))
|
||||
|
||||
/** 一键美化:json 原生,其余 prettier standalone(动态加载);语法错误只提示不改动 */
|
||||
async function beautify() {
|
||||
try {
|
||||
const out = await formatSource(editText.value, ext.value)
|
||||
if (out === null) return
|
||||
if (out === editText.value) {
|
||||
message.success('已是规范格式')
|
||||
return
|
||||
}
|
||||
editText.value = out
|
||||
message.success('已格式化')
|
||||
} catch {
|
||||
message.error('格式化失败:内容存在语法错误')
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!props.cfgId || saving.value) return
|
||||
if (!pendingConfirm.value && !props.versioningOn) {
|
||||
pendingConfirm.value = '该桶未开启版本控制,保存将直接覆盖原内容,不可恢复。'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const { etag } = await putObjectContent(
|
||||
props.cfgId,
|
||||
props.bucket,
|
||||
props.object,
|
||||
editText.value,
|
||||
contentType.value || 'text/plain',
|
||||
overwrite.value ? undefined : contentEtag.value || undefined,
|
||||
props.region,
|
||||
)
|
||||
applySaved(etag)
|
||||
} catch (e) {
|
||||
onSaveError(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applySaved(etag: string) {
|
||||
contentEtag.value = etag
|
||||
setText(editText.value)
|
||||
message.success('已保存')
|
||||
mode.value = 'preview'
|
||||
resetEdit()
|
||||
emit('changed')
|
||||
void detail.run({ silent: true })
|
||||
}
|
||||
|
||||
function onSaveError(e: unknown) {
|
||||
if (e instanceof ApiError && e.status === 412) {
|
||||
overwrite.value = true
|
||||
pendingConfirm.value = '对象在编辑期间已被修改(ETag 变化),继续保存将覆盖他人的更改。'
|
||||
return
|
||||
}
|
||||
message.error(e instanceof Error ? e.message : '保存失败')
|
||||
}
|
||||
|
||||
// ---- 重命名 / 删除 / 复制 ----
|
||||
const renaming = ref(false)
|
||||
const newName = ref('')
|
||||
const renameBusy = ref(false)
|
||||
|
||||
function startRename() {
|
||||
newName.value = props.object
|
||||
renaming.value = true
|
||||
}
|
||||
|
||||
async function doRename() {
|
||||
if (!props.cfgId || !newName.value.trim() || newName.value === props.object) return
|
||||
renameBusy.value = true
|
||||
try {
|
||||
await renameObject(props.cfgId, props.bucket, props.object, newName.value.trim(), props.region)
|
||||
message.success('已重命名')
|
||||
emit('update:show', false)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '重命名失败')
|
||||
} finally {
|
||||
renameBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await deleteObject(props.cfgId, props.bucket, props.object, props.region)
|
||||
message.success('对象已删除')
|
||||
emit('update:show', false)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** Archive 层(任意恢复态):PAR 对其无效,分享一律禁用 */
|
||||
const isArchiveTier = computed(() => detail.data.value?.storageTier === 'Archive')
|
||||
/** 公共读桶:对象有公网直链,「复制 URI」升级为「复制 URL」 */
|
||||
const isPublicBucket = computed(() => props.visibility === 'ObjectRead')
|
||||
|
||||
const scope = useScopeStore()
|
||||
|
||||
/** 公共读桶对象的公网直链;对象名整体转义(/ → %2F,OCI 控制台同款) */
|
||||
function publicUrl(): string {
|
||||
const region = props.region || scope.region
|
||||
return `https://objectstorage.${region}.oraclecloud.com/n/${props.namespace}/b/${props.bucket}/o/${encodeURIComponent(props.object)}`
|
||||
}
|
||||
|
||||
async function copyUri() {
|
||||
const pub = isPublicBucket.value
|
||||
await navigator.clipboard.writeText(pub ? publicUrl() : `oci://${props.bucket}/${props.object}`)
|
||||
message.success(pub ? 'URL 已复制' : 'URI 已复制')
|
||||
}
|
||||
|
||||
const kvRows = computed(() => {
|
||||
const d = detail.data.value
|
||||
if (!d) return []
|
||||
return [
|
||||
{ k: '大小', v: `${fmtBytes(d.size)}(${d.size.toLocaleString()} B)` },
|
||||
{ k: 'Content-Type', v: d.contentType || '—', mono: true },
|
||||
{ k: 'ETag', v: d.etag || '—', mono: true },
|
||||
{ k: 'MD5', v: d.contentMd5 || '—', mono: true },
|
||||
{ k: '存储层', v: d.storageTier || 'Standard' },
|
||||
{ k: '版本控制', v: props.versioningOn ? '已开启' : '未开启' },
|
||||
{ k: '修改时间', v: fmtTime(d.timeModified) },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:mask-closable="mode !== 'edit'"
|
||||
:style="modalStyle"
|
||||
:class="isMobile ? 'form-modal-mobile' : ''"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template #header>
|
||||
<span class="mono flex min-w-0 items-center gap-2 text-[13.5px]">
|
||||
<FileIcon :name="object" :size="18" />
|
||||
<span class="truncate" :title="object">{{ object }}</span>
|
||||
<span
|
||||
v-if="mode === 'edit'"
|
||||
class="flex-none rounded bg-wash px-1.5 py-px text-[10.5px] font-medium text-ink-3"
|
||||
>
|
||||
编辑
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex min-h-0 flex-col gap-4 md:flex-row max-md:flex-1 max-md:overflow-y-auto">
|
||||
<!-- 左:内容区(移动端全屏形态下压缩预览高度,信息与操作随外层滚动) -->
|
||||
<div
|
||||
class="flex h-[65vh] min-w-0 flex-1 flex-col rounded-lg border border-line-soft bg-wash/50 p-3 max-md:h-auto max-md:min-h-[45dvh] max-md:flex-none"
|
||||
>
|
||||
<div
|
||||
v-if="detail.loading.value || loading"
|
||||
class="flex flex-1 items-center justify-center"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="blockedReason"
|
||||
class="flex flex-1 items-center justify-center text-xs text-ink-3"
|
||||
>
|
||||
{{ blockedReason }}
|
||||
</div>
|
||||
<div v-else-if="loadError" class="flex flex-1 flex-col items-center justify-center gap-2">
|
||||
<div class="text-xs text-err">{{ loadError }}</div>
|
||||
<NButton size="small" @click="loadContent">重试</NButton>
|
||||
</div>
|
||||
<template v-else-if="mode === 'edit'">
|
||||
<div
|
||||
class="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-white transition-colors focus-within:border-accent"
|
||||
>
|
||||
<TextEditPane v-model="editText" :object-name="object" :disabled="saving" />
|
||||
</div>
|
||||
<div class="mt-1.5 flex-none text-xs text-ink-3">
|
||||
UTF-8 文本 · {{ fmtBytes(editByteSize) }} · 保存经面板中转写回,If-Match 防并发覆盖
|
||||
</div>
|
||||
</template>
|
||||
<img
|
||||
v-else-if="imageUrl"
|
||||
:src="imageUrl"
|
||||
:alt="object"
|
||||
class="mx-auto max-h-full min-h-0 rounded object-contain"
|
||||
@error="loadError = '图片加载失败,请下载查看'; revokeImage()"
|
||||
/>
|
||||
<TextPreviewPane
|
||||
v-else-if="kind === 'text' && (previewText || truncated || !loading)"
|
||||
:text="previewText"
|
||||
:ext="ext"
|
||||
:truncated="truncated"
|
||||
/>
|
||||
<template v-else-if="officeData && officeKind">
|
||||
<div
|
||||
v-if="officeError"
|
||||
class="flex flex-1 items-center justify-center text-xs text-err"
|
||||
>
|
||||
{{ officeError }}
|
||||
</div>
|
||||
<div v-else class="min-h-0 flex-1 overflow-auto rounded bg-white">
|
||||
<OfficePreview :kind="officeKind" :data="officeData" @error="officeError = $event" />
|
||||
</div>
|
||||
<div v-if="officeKind === 'pptx' && !officeError" class="mt-1.5 flex-none text-xs text-ink-3">
|
||||
pptx 预览为实验能力,复杂版式可能失真
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 右:信息与操作 -->
|
||||
<div class="flex w-full flex-none flex-col gap-3 md:w-72">
|
||||
<div v-if="detail.loading.value" class="flex justify-center py-10">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div
|
||||
v-for="row in kvRows"
|
||||
:key="row.k"
|
||||
class="flex justify-between gap-3 border-b border-line-soft py-1.5 text-[12px]"
|
||||
>
|
||||
<span class="flex-none text-ink-3">{{ row.k }}</span>
|
||||
<span class="min-w-0 text-right break-all" :class="row.mono ? 'mono' : ''">
|
||||
{{ row.v }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="mode === 'edit'">
|
||||
<div
|
||||
v-if="pendingConfirm"
|
||||
class="rounded-lg bg-warn/12 px-3 py-2 text-xs leading-relaxed text-warn"
|
||||
>
|
||||
{{ pendingConfirm }}
|
||||
</div>
|
||||
<NButton v-if="canBeautify" size="small" :disabled="saving" @click="beautify">
|
||||
美化格式
|
||||
</NButton>
|
||||
<div class="flex gap-2">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
class="flex-1"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
>
|
||||
{{ pendingConfirm ? '确认覆盖并保存' : '保存' }}
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="saving" @click="cancelEdit">取消</NButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="renaming" class="flex flex-col gap-2">
|
||||
<NInput v-model:value="newName" size="small" class="mono" />
|
||||
<div class="flex gap-2">
|
||||
<NButton size="small" type="primary" :loading="renameBusy" @click="doRename">
|
||||
确定
|
||||
</NButton>
|
||||
<NButton size="small" :disabled="renameBusy" @click="renaming = false">取消</NButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div :title="archived ? 'Archive 对象需先恢复才能下载' : ''">
|
||||
<NButton
|
||||
size="small"
|
||||
type="primary"
|
||||
block
|
||||
:disabled="archived"
|
||||
@click="emit('download', object)"
|
||||
>
|
||||
下载
|
||||
</NButton>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<NButton v-if="canEdit" size="small" @click="startEdit">编辑</NButton>
|
||||
<div :title="isArchiveTier ? 'Archive 层对象不支持预签名分享' : ''">
|
||||
<NButton size="small" block :disabled="isArchiveTier" @click="emit('share', object)">
|
||||
分享
|
||||
</NButton>
|
||||
</div>
|
||||
<NButton size="small" @click="copyUri">
|
||||
{{ isPublicBucket ? '复制 URL' : '复制 URI' }}
|
||||
</NButton>
|
||||
<NButton size="small" @click="startRename">重命名</NButton>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
title="删除该对象?"
|
||||
content="未开版本控制时不可恢复。"
|
||||
confirm-text="删除"
|
||||
:on-confirm="doDelete"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost block>删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
|
||||
import { deleteAllPars, deletePar, listPars } from '@/api/objectstorage'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { Par, ParPage } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number | null; region?: string; bucket: string }>()
|
||||
const message = useToast()
|
||||
|
||||
/** 服务端游标分页:几千条 PAR 的桶全量拉取会把浏览器主线程打满数秒 */
|
||||
const PAGE_SIZE = 100
|
||||
/** 上页游标栈:后端只回传 next 游标,上一页靠历史栈回放 */
|
||||
const cursorStack = ref<string[]>([])
|
||||
const cursor = ref('')
|
||||
|
||||
const pars = useAsync<ParPage>(async () => {
|
||||
if (!props.cfgId) return { items: [], nextPage: '' }
|
||||
try {
|
||||
return await listPars(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
page: cursor.value || undefined,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
} catch {
|
||||
return { items: [], nextPage: '' }
|
||||
}
|
||||
}, false)
|
||||
|
||||
const items = computed(() => pars.data.value?.items ?? [])
|
||||
|
||||
watch(
|
||||
// region 必须在依赖里:切区域后旧列表配新区域,撤销会删错区域的同名 PAR
|
||||
[() => props.cfgId, () => props.region, () => props.bucket],
|
||||
() => {
|
||||
cursorStack.value = []
|
||||
cursor.value = ''
|
||||
void pars.run()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function goNext() {
|
||||
const next = pars.data.value?.nextPage
|
||||
if (!next) return
|
||||
cursorStack.value.push(cursor.value)
|
||||
cursor.value = next
|
||||
void pars.run()
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
cursor.value = cursorStack.value.pop() ?? ''
|
||||
void pars.run()
|
||||
}
|
||||
|
||||
/** 回到首页重拉;签发新 PAR 后的刷新走这里(新条目排序位置不定,回首页语义最直观) */
|
||||
function refreshFirstPage(opts?: { silent?: boolean }) {
|
||||
cursorStack.value = []
|
||||
cursor.value = ''
|
||||
void pars.run(opts)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: () => refreshFirstPage({ silent: true }) })
|
||||
|
||||
async function remove(row: Par) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
await deletePar(props.cfgId, props.bucket, row.id, props.region)
|
||||
message.success('分享链接已删除并立即失效')
|
||||
await pars.run({ silent: true })
|
||||
// 当前页删空且非首页时自动退回上一页
|
||||
if (!items.value.length && cursorStack.value.length) goPrev()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAll() {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
const { deleted } = await deleteAllPars(props.cfgId, props.bucket, props.region)
|
||||
message.success(`已删除 ${deleted} 条分享链接`)
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
} finally {
|
||||
refreshFirstPage()
|
||||
}
|
||||
}
|
||||
|
||||
const ACCESS_LABEL: Record<string, string> = {
|
||||
ObjectRead: '只读',
|
||||
ObjectWrite: '只写',
|
||||
ObjectReadWrite: '读写',
|
||||
AnyObjectRead: '桶级只读',
|
||||
AnyObjectWrite: '桶级只写',
|
||||
AnyObjectReadWrite: '桶级读写',
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<Par>>(() => [
|
||||
{
|
||||
title: '目标',
|
||||
key: 'objectName',
|
||||
minWidth: 220,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (r) => h('span', { class: 'mono text-[12.5px]' }, r.objectName || '(桶级)'),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
key: 'accessType',
|
||||
width: 120,
|
||||
render: (r) =>
|
||||
h(
|
||||
'span',
|
||||
{
|
||||
class:
|
||||
'rounded-full bg-wash px-2 py-0.5 text-[11.5px] font-medium whitespace-nowrap text-ink-2',
|
||||
},
|
||||
ACCESS_LABEL[r.accessType] ?? r.accessType,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '过期时间',
|
||||
key: 'timeExpires',
|
||||
width: 160,
|
||||
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeExpires)),
|
||||
},
|
||||
{
|
||||
title: '创建',
|
||||
key: 'timeCreated',
|
||||
width: 150,
|
||||
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 90,
|
||||
render: (r) =>
|
||||
h(
|
||||
ConfirmPop,
|
||||
{
|
||||
title: '删除该临时链接?',
|
||||
content: '删除后链接立即失效。',
|
||||
confirmText: '删除',
|
||||
onConfirm: () => remove(r),
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'error' },
|
||||
{ default: () => '删除' },
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<PanelHeader
|
||||
title="分享链接(PAR)"
|
||||
desc="完整 URL 仅签发时展示一次(OCI 不支持事后取回);删除后链接立即失效"
|
||||
>
|
||||
<template #title-extra>
|
||||
<span class="text-[13px] font-normal text-ink-3">
|
||||
· 本页 {{ pars.data.value ? items.length : '…' }}{{ pars.data.value?.nextPage ? '+' : '' }} 条
|
||||
</span>
|
||||
</template>
|
||||
<ConfirmPop
|
||||
title="删除全部分享链接?"
|
||||
content="将删除桶内全部分享链接(含未展示分页),删除后全部立即失效。"
|
||||
confirm-text="全部删除"
|
||||
:on-confirm="removeAll"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary :disabled="!items.length">
|
||||
删除全部
|
||||
</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</PanelHeader>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="items"
|
||||
:loading="pars.loading.value"
|
||||
:pagination="false"
|
||||
:scroll-x="740"
|
||||
:max-height="440"
|
||||
:row-key="(r: Par) => r.id"
|
||||
/>
|
||||
<div
|
||||
v-if="cursorStack.length || pars.data.value?.nextPage"
|
||||
class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<NButton size="tiny" quaternary :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { LanguageFn } from 'highlight.js'
|
||||
|
||||
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'])
|
||||
const TEXT_EXT = new Set([
|
||||
'txt', 'md', 'log', 'json', 'yaml', 'yml', 'ini', 'toml', 'conf', 'cfg', 'csv',
|
||||
'xml', 'html', 'htm', 'css', 'js', 'ts', 'sh', 'py', 'go', 'env', 'properties', 'sql',
|
||||
])
|
||||
|
||||
/** office/pdf 预览形态(vue-office 渲染);doc/xls/ppt 老格式不支持 */
|
||||
const OFFICE_KIND: Record<string, 'docx' | 'xlsx' | 'pdf' | 'pptx'> = {
|
||||
docx: 'docx',
|
||||
xlsx: 'xlsx',
|
||||
pdf: 'pdf',
|
||||
pptx: 'pptx',
|
||||
}
|
||||
|
||||
export type PreviewKind = 'image' | 'text' | 'docx' | 'xlsx' | 'pdf' | 'pptx' | 'none'
|
||||
|
||||
export function extOf(name: string): string {
|
||||
return name.split('.').pop()?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
/** 对象是否支持预览及预览形态 */
|
||||
export function previewKindOf(name: string): PreviewKind {
|
||||
const ext = extOf(name)
|
||||
// svg 按文本处理:预览「渲染 ↔ 代码」双态由文本生成,且可在线编辑
|
||||
if (ext === 'svg') return 'text'
|
||||
if (IMAGE_EXT.has(ext)) return 'image'
|
||||
if (TEXT_EXT.has(ext)) return 'text'
|
||||
return OFFICE_KIND[ext] ?? 'none'
|
||||
}
|
||||
|
||||
/** 扩展名 → highlight.js 语言;不在表内的按纯文本展示 */
|
||||
const EXT_LANG: Record<string, string> = {
|
||||
py: 'python',
|
||||
json: 'json',
|
||||
yaml: 'yaml',
|
||||
yml: 'yaml',
|
||||
ini: 'ini',
|
||||
toml: 'ini',
|
||||
conf: 'ini',
|
||||
cfg: 'ini',
|
||||
env: 'ini',
|
||||
properties: 'ini',
|
||||
xml: 'xml',
|
||||
html: 'xml',
|
||||
htm: 'xml',
|
||||
sh: 'bash',
|
||||
js: 'javascript',
|
||||
ts: 'typescript',
|
||||
go: 'go',
|
||||
sql: 'sql',
|
||||
md: 'markdown',
|
||||
css: 'css',
|
||||
svg: 'xml',
|
||||
}
|
||||
|
||||
/** 语言按需动态加载,各自成 chunk,不预览不下载 */
|
||||
const LANG_LOADERS: Record<string, () => Promise<{ default: LanguageFn }>> = {
|
||||
python: () => import('highlight.js/lib/languages/python'),
|
||||
json: () => import('highlight.js/lib/languages/json'),
|
||||
yaml: () => import('highlight.js/lib/languages/yaml'),
|
||||
ini: () => import('highlight.js/lib/languages/ini'),
|
||||
xml: () => import('highlight.js/lib/languages/xml'),
|
||||
bash: () => import('highlight.js/lib/languages/bash'),
|
||||
javascript: () => import('highlight.js/lib/languages/javascript'),
|
||||
typescript: () => import('highlight.js/lib/languages/typescript'),
|
||||
go: () => import('highlight.js/lib/languages/go'),
|
||||
sql: () => import('highlight.js/lib/languages/sql'),
|
||||
markdown: () => import('highlight.js/lib/languages/markdown'),
|
||||
css: () => import('highlight.js/lib/languages/css'),
|
||||
}
|
||||
|
||||
/**
|
||||
* 高亮一段代码;lang 可传语言名(md 围栏标签,如 python/bash)或扩展名(如 py/sh)。
|
||||
* 返回 hljs 生成的 HTML(输入已转义,可安全 v-html);不支持或失败返回 null。
|
||||
*/
|
||||
export async function highlightCode(code: string, lang: string): Promise<string | null> {
|
||||
const name = LANG_LOADERS[lang] ? lang : EXT_LANG[lang]
|
||||
const load = name ? LANG_LOADERS[name] : undefined
|
||||
if (!name || !load) return null
|
||||
try {
|
||||
const { default: hljs } = await import('highlight.js/lib/core')
|
||||
if (!hljs.getLanguage(name)) hljs.registerLanguage(name, (await load()).default)
|
||||
return hljs.highlight(code, { language: name }).value
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 按扩展名高亮整份源码,语义同 highlightCode(ext 分支)。 */
|
||||
export async function highlightSource(text: string, ext: string): Promise<string | null> {
|
||||
return highlightCode(text, ext)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 由 scripts/gen-file-icons.mjs 从 @iconify-json/catppuccin 提取,勿手改;
|
||||
// 来源:Catppuccin VSCode Icons(MIT),16x16 viewBox。
|
||||
export const FILE_ICON_BODIES: Record<string, string> = {
|
||||
"folder":
|
||||
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M4.5 4.5H12c.83 0 1.5.67 1.5 1.5v6c0 .83-.67 1.5-1.5 1.5H2A1.5 1.5 0 0 1 .5 12V3.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v1"/>',
|
||||
"file":
|
||||
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M13.5 6.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01m-.01 0l5 5h-4a1 1 0 0 1-1-1z"/>',
|
||||
"image":
|
||||
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#eed49f" d="M11.5 6A1.5 1.5 0 0 1 10 7.5A1.5 1.5 0 0 1 8.5 6A1.5 1.5 0 0 1 10 4.5A1.5 1.5 0 0 1 11.5 6"/><path stroke="#a6da95" d="M7.5 13.5L11 10c.5-.5 1.5-.5 2 0l1.5 1.5"/><path stroke="#a6da95" d="m1.5 9.5l2-2C4 7 5 7 5.5 7.5l4 4"/><path stroke="#7dc4e4" d="M3 2.5h10c.83 0 1.5.67 1.5 1.5v8c0 .83-.67 1.5-1.5 1.5H3A1.5 1.5 0 0 1 1.5 12V4c0-.83.67-1.5 1.5-1.5"/></g>',
|
||||
"json":
|
||||
'<path fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round" d="M4.5 2.5H4c-.75 0-1.5.75-1.5 1.5v2c0 1.1-1 2-1.83 2c.83 0 1.83.9 1.83 2v2c0 .75.75 1.5 1.5 1.5h.5m7-11h.5c.75 0 1.5.75 1.5 1.5v2c0 1.1 1 2 1.83 2c-.83 0-1.83.9-1.83 2v2c0 .74-.75 1.5-1.5 1.5h-.5m-6.5-3a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1m3 0a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1m3 0a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1"/>',
|
||||
"yaml":
|
||||
'<path fill="none" stroke="#ed8796" stroke-linecap="round" stroke-linejoin="round" d="M2.5 1.5h3l3 4l3-4h3l-9 13h-3L7 8z"/>',
|
||||
"toml":
|
||||
'<path fill="none" stroke="#ee99a0" stroke-linecap="round" stroke-linejoin="round" d="M3.5 1.5h-2v13h2m9-13h2v13h-2m-8-11h7v3h-2v6h-3v-6h-2z"/>',
|
||||
"properties":
|
||||
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M8 1.5c-.87 0-1.17 1.32-2.03 1.63c-.86.3-2.17-.68-2.84 0c-.68.67.3 1.98 0 2.84S1.5 7.13 1.5 8s1.32 1.17 1.63 2.03c.3.86-.68 2.17 0 2.85c.67.67 1.98-.3 2.84 0c.85.3 1.16 1.62 2.03 1.62s1.17-1.32 2.03-1.63c.86-.3 2.17.68 2.85 0c.67-.67-.3-1.98 0-2.84c.3-.85 1.62-1.16 1.62-2.03s-1.32-1.17-1.63-2.03c-.3-.86.68-2.17 0-2.84c-.67-.68-1.98.3-2.84 0S8.87 1.5 8 1.5m0 9a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5"/>',
|
||||
"python":
|
||||
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#8aadf4" d="M8.5 5.5h-3m6 0V3c0-.8-.7-1.5-1.5-1.5H7c-.8 0-1.5.7-1.5 1.5v2.5H3c-.8 0-1.5.7-1.5 1.5v2c0 .8.7 1.5 1.48 1.5"/><path stroke="#eed49f" d="M10.5 10.5h-3m-3 0V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.5H13c.8 0 1.5-.7 1.5-1.5V7c0-.8-.7-1.5-1.48-1.5H11.5c0 1.5 0 2-1 2h-2"/><path stroke="#8aadf4" d="M2.98 10.5H4.5c0-1.5 0-2 1-2h2m0-5"/></g>',
|
||||
"javascript":
|
||||
'<g fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 11a1.5 1.5 0 0 0 3 0V7.5m5 1.25c0-.69-.537-1.25-1.2-1.25h-.6c-.663 0-1.2.56-1.2 1.25S10.037 10 10.7 10h.6c.663 0 1.2.56 1.2 1.25s-.537 1.25-1.2 1.25h-.6c-.663 0-1.2-.56-1.2-1.25"/><path d="M4 1.5h8c1.385 0 2.5 1.115 2.5 2.5v8c0 1.385-1.115 2.5-2.5 2.5H4A2.495 2.495 0 0 1 1.5 12V4c0-1.385 1.115-2.5 2.5-2.5"/></g>',
|
||||
"typescript":
|
||||
'<g fill="none" stroke="#8aadf4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 1.5h8A2.5 2.5 0 0 1 14.5 4v8a2.5 2.5 0 0 1-2.5 2.5H4A2.5 2.5 0 0 1 1.5 12V4A2.5 2.5 0 0 1 4 1.5"/><path d="M12.5 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S10.04 10 10.7 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25m-3-3.75v5M5 7.5h3"/></g>',
|
||||
"markdown":
|
||||
'<path fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round" d="m9.25 8.25l2.25 2.25l2.25-2.25M3.5 11V5.5l2.04 3l1.96-3V11m4-.5V5M1.65 2.5h12.7c.59 0 1.15.49 1.15 1v9c0 .51-.56 1-1.15 1H1.65c-.59 0-1.15-.49-1.15-1V3.58c0-.5.56-1.08 1.15-1.08"/>',
|
||||
"database":
|
||||
'<path fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round" d="M8 6.5c3.59 0 6.5-1.4 6.5-2.68S11.59 1.5 8 1.5S1.5 2.54 1.5 3.82S4.41 6.5 8 6.5M14.5 8c0 .83-1.24 1.79-3.25 2.2s-4.49.41-6.5 0S1.5 8.83 1.5 8m13 4.18c0 .83-1.24 1.6-3.25 2c-2.01.42-4.49.42-6.5 0c-2.01-.4-3.25-1.17-3.25-2m0-8.3v8.3m13-8.3v8.3"/>',
|
||||
"bash":
|
||||
'<g fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round"><path d="M2 15.5c-.7 0-1.5-.8-1.5-1.5V5c0-.7.8-1.5 1.5-1.5h9c.7 0 1.5.8 1.5 1.5v9c0 .7-.8 1.5-1.5 1.5z"/><path d="m1.2 3.8l3.04-2.5S5.17.5 5.7.5h8.4c.66 0 1.4.73 1.4 1.4v7.73a2.7 2.7 0 0 1-.7 1.75l-2.68 3.51"/><path d="M6 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S3.54 10 4.2 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25M4.5 6.5v1m0 5v1"/></g>',
|
||||
"go":
|
||||
'<path fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round" d="m15.48 8.06l-4.85.48m4.85-.48a4.98 4.98 0 0 1-4.54 5.42a5 5 0 1 1 2.95-8.66l-1.7 1.84a2.5 2.5 0 0 0-4.18 2.06c.05.57.3 1.1.69 1.51c.25.27 1 .83 1.78.82c.8-.02 1.58-.25 2.07-.81c0 0 .8-.96.68-1.88M2.5 8.5l-2 .01m1.5 2h1.5m-2-3.99l2-.02"/>',
|
||||
"zip":
|
||||
'<path fill="none" stroke="#cad3f5" stroke-linejoin="round" d="M5.5 10v1m1-2v1m-1-2v1m1-2v1m-1-2v1m1-2v1m-1-2v1m0-3v1m1 0v1m7 2.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01m-.01 0l5 5h-4a1 1 0 0 1-1-1z"/>',
|
||||
"pdf":
|
||||
'<path fill="none" stroke="#ed8796" stroke-linecap="round" stroke-linejoin="round" d="M2.8 14.34c1.81-1.25 3.02-3.16 3.91-5.5c.9-2.33 1.86-4.33 1.44-6.63c-.06-.36-.57-.73-.83-.7c-1.02.06-.95 1.21-.85 1.9c.24 1.71 1.56 3.7 2.84 5.56c1.27 1.87 2.32 2.16 3.78 2.26c.5.03 1.25-.14 1.37-.58c.77-2.8-9.02-.54-12.28 2.08c-.4.33-.86 1-.6 1.46c.2.36.87.4 1.23.15h0Z"/>',
|
||||
"video":
|
||||
'<g fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2.5h10c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5H3A1.5 1.5 0 0 1 1.5 13V4c0-.83.67-1.5 1.5-1.5m-1.5 3h13"/><path d="m3.5 5.5l2-3m1.5 3l2-3m1.5 3l2-3M6.5 8v4l4-2z"/></g>',
|
||||
"audio":
|
||||
'<g fill="none" stroke="#ee99a0" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 12.5a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2m9-2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2"/><path d="M5.5 12.5V5c0-.54.44-1.21 1.35-1.5l6.3-2c.9 0 1.35.88 1.35 1.5v7.58m-9-3.08l9-3"/></g>',
|
||||
"html":
|
||||
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#f5a97f" d="M1.5 1.5h13L13 13l-5 2l-5-2z"/><path stroke="#cad3f5" d="M11 4.5H5l.25 3h5.5l-.25 3l-2.5 1l-2.5-1l-.08-1"/></g>',
|
||||
"css":
|
||||
'<g fill="none" stroke="#c6a0f6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 1.5h8A2.5 2.5 0 0 1 14.5 4v8a2.5 2.5 0 0 1-2.5 2.5H4A2.5 2.5 0 0 1 1.5 12V4A2.5 2.5 0 0 1 4 1.5"/><path stroke-width=".814" d="M10.24 11.53c0 .58.438 1.038.96 1.035l.453-.004c.522-.003.949-.451.949-1.033c0-.58-.427-1.065-.95-1.065h-.451c-.523 0-.95-.486-.95-1.066s.427-1.038.95-1.038h.452c.522 0 .951.458.951 1.039M6.8 11.529c0 .58.438 1.04.96 1.036l.465-.004c.523-.003.936-.451.936-1.031s-.409-1.066-.931-1.066h-.47c-.522 0-.949-.485-.949-1.066c0-.58.427-1.037.95-1.037h.451c.523 0 .964.457.964 1.037M3.407 11.53c0 .58.438 1.052.96 1.052h.452c.522 0 .95-.457.95-1.038m.01-2.131c0-.58-.437-1.038-.96-1.038h-.451c-.523 0-.96.468-.96 1.05v2.118"/></g>',
|
||||
"text":
|
||||
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 6.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01"/><path d="m8.5 1.5l5 5h-4a1 1 0 0 1-1-1zm-3 10h5m-5-3h5m-5-3h1"/></g>',
|
||||
"log":
|
||||
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 3.5h9v11h-9z"/><path d="M11.5 3.45V1.5h-9v11h1.95m3.05-5h3m-3 3h3"/></g>',
|
||||
"csv":
|
||||
'<path fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round" d="M1.5 3.5c0-.54.48-1 1.08-1H6.5l1.54 1h5.38c.6 0 1.08.44 1.08.98l-.09 9.04c0 .54-.48.98-1.08.98H2.58c-.6 0-1.08-.44-1.08-.98zm2 4v4m3-4v4m3-4v4m3-4v4m-9 0h9m-9-2h9m-9-2h9"/>',
|
||||
"xml":
|
||||
'<path fill="none" stroke="#f5a97f" stroke-linecap="round" stroke-linejoin="round" d="M4.5 4.5L1 8l3.5 3.5m7-7L15 8l-3.5 3.5M9.5 2l-3 12"/>',
|
||||
"binary":
|
||||
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 1.5h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2"/><path d="M10.5 9.5h1v3.05M6 9.5h1c.28 0 .5.22.5.5v2a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5v-2c0-.28.22-.5.5-.5m4-6h1c.28 0 .5.22.5.5v2a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5V4c0-.28.22-.5.5-.5m-4.5 0h1v3.05"/></g>',
|
||||
"ms-word":
|
||||
'<g fill="none" stroke="#8aadf4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62"/><path d="m.5 5.5l1 5l1-5l1 5l.97-5m3.03 1h4m-4 3h4"/></g>',
|
||||
"ms-excel":
|
||||
'<g fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62M.5 5.5l4 5m0-5l-4 5"/><path d="M7.5 5.5h5v5h-5zm2 0v5m-2-3h5"/></g>',
|
||||
"ms-powerpoint":
|
||||
'<g fill="none" stroke="#f5a97f" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62"/><path d="M7.5 5.8L11.88 8L7.5 10.2zm-7-.3v5m0-2H2a1.5 1.5 0 0 0 0-3H.5"/></g>',
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Plugin } from 'prettier'
|
||||
|
||||
/** prettier 各格式的 parser 与插件加载器;插件按需动态 import,各自成 chunk */
|
||||
const PRETTIER_CFG: Record<string, { parser: string; plugins: (() => Promise<unknown>)[] }> = {
|
||||
yaml: { parser: 'yaml', plugins: [() => import('prettier/plugins/yaml')] },
|
||||
yml: { parser: 'yaml', plugins: [() => import('prettier/plugins/yaml')] },
|
||||
css: { parser: 'css', plugins: [() => import('prettier/plugins/postcss')] },
|
||||
md: { parser: 'markdown', plugins: [() => import('prettier/plugins/markdown')] },
|
||||
html: {
|
||||
parser: 'html',
|
||||
plugins: [
|
||||
() => import('prettier/plugins/html'),
|
||||
() => import('prettier/plugins/postcss'),
|
||||
() => import('prettier/plugins/babel'),
|
||||
() => import('prettier/plugins/estree'),
|
||||
],
|
||||
},
|
||||
htm: {
|
||||
parser: 'html',
|
||||
plugins: [
|
||||
() => import('prettier/plugins/html'),
|
||||
() => import('prettier/plugins/postcss'),
|
||||
() => import('prettier/plugins/babel'),
|
||||
() => import('prettier/plugins/estree'),
|
||||
],
|
||||
},
|
||||
js: {
|
||||
parser: 'babel',
|
||||
plugins: [() => import('prettier/plugins/babel'), () => import('prettier/plugins/estree')],
|
||||
},
|
||||
ts: {
|
||||
parser: 'babel-ts',
|
||||
plugins: [() => import('prettier/plugins/babel'), () => import('prettier/plugins/estree')],
|
||||
},
|
||||
xml: { parser: 'xml', plugins: [() => import('@prettier/plugin-xml')] },
|
||||
svg: { parser: 'xml', plugins: [() => import('@prettier/plugin-xml')] },
|
||||
}
|
||||
|
||||
/** 该扩展名是否支持一键美化 */
|
||||
export function canFormat(ext: string): boolean {
|
||||
return ext === 'json' || ext in PRETTIER_CFG
|
||||
}
|
||||
|
||||
/** 解开插件模块的 default 包装(prettier 插件 ESM 互操作) */
|
||||
function unwrap(m: unknown): Plugin {
|
||||
const obj = m as { default?: Plugin }
|
||||
return obj.default ?? (m as Plugin)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化源码;json 用原生 JSON 美化,其余经 prettier standalone。
|
||||
* 不支持的格式返回 null;语法错误抛异常由调用方提示。
|
||||
*/
|
||||
export async function formatSource(text: string, ext: string): Promise<string | null> {
|
||||
if (ext === 'json') return JSON.stringify(JSON.parse(text), null, 2)
|
||||
const cfg = PRETTIER_CFG[ext]
|
||||
if (!cfg) return null
|
||||
const { format } = await import('prettier/standalone')
|
||||
const plugins = await Promise.all(cfg.plugins.map((p) => p().then(unwrap)))
|
||||
return format(text, { parser: cfg.parser, plugins })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
/** office/pdf 渲染:vue-office 组件按需异步加载,各自独立 chunk,不预览不下载 */
|
||||
const props = defineProps<{
|
||||
kind: 'docx' | 'xlsx' | 'pdf' | 'pptx'
|
||||
data: ArrayBuffer
|
||||
}>()
|
||||
const emit = defineEmits<{ error: [string] }>()
|
||||
|
||||
const comps = {
|
||||
docx: defineAsyncComponent(async () => {
|
||||
await import('@vue-office/docx/lib/index.css')
|
||||
return (await import('@vue-office/docx')).default
|
||||
}),
|
||||
xlsx: defineAsyncComponent(async () => {
|
||||
await import('@vue-office/excel/lib/index.css')
|
||||
return (await import('@vue-office/excel')).default
|
||||
}),
|
||||
pdf: defineAsyncComponent(() => import('@vue-office/pdf').then((m) => m.default)),
|
||||
pptx: defineAsyncComponent(() => import('@vue-office/pptx').then((m) => m.default)),
|
||||
}
|
||||
const comp = computed(() => comps[props.kind])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="comp"
|
||||
:src="data"
|
||||
class="office-host"
|
||||
@error="emit('error', '文档渲染失败,请下载查看')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.office-host {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
import { NSpin } from 'naive-ui'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { extOf } from '@/components/objectstorage/codeHighlight'
|
||||
|
||||
/** 文本编辑器:CodeMirror 6,行号 / 语法高亮 / 搜索;
|
||||
* 核心与语言包全部动态 import 独立 chunk,不进编辑不下载。
|
||||
* 须放在定高容器内(内部自滚动)。 */
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
objectName: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [string] }>()
|
||||
|
||||
type CmExtension = import('@codemirror/state').Extension
|
||||
type CmEditorView = typeof import('@codemirror/view').EditorView
|
||||
|
||||
const host = ref<HTMLElement | null>(null)
|
||||
const booting = ref(true)
|
||||
const bootError = ref('')
|
||||
|
||||
let view: import('@codemirror/view').EditorView | null = null
|
||||
let EV: CmEditorView | null = null
|
||||
let editableComp: import('@codemirror/state').Compartment | null = null
|
||||
|
||||
onMounted(() => void boot())
|
||||
onBeforeUnmount(() => view?.destroy())
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const [{ basicSetup }, { EditorView }, { Compartment }, lang, theme] = await Promise.all([
|
||||
import('codemirror'),
|
||||
import('@codemirror/view'),
|
||||
import('@codemirror/state'),
|
||||
loadLanguage(extOf(props.objectName)),
|
||||
themeExtensions(),
|
||||
])
|
||||
EV = EditorView
|
||||
editableComp = new Compartment()
|
||||
view = new EditorView({
|
||||
parent: host.value!,
|
||||
doc: props.modelValue,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
EditorView.lineWrapping,
|
||||
...(lang ? [lang] : []),
|
||||
...theme,
|
||||
editableComp.of(EditorView.editable.of(!props.disabled)),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) emit('update:modelValue', u.state.doc.toString())
|
||||
}),
|
||||
],
|
||||
})
|
||||
} catch (e) {
|
||||
bootError.value = e instanceof Error ? e.message : '编辑器加载失败'
|
||||
} finally {
|
||||
booting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 主题与语法配色引用设计 token(明暗自适应),语义与全局 .code-hl 一致 */
|
||||
async function themeExtensions(): Promise<CmExtension[]> {
|
||||
const [{ HighlightStyle, syntaxHighlighting }, { tags: t }, { EditorView }] = await Promise.all([
|
||||
import('@codemirror/language'),
|
||||
import('@lezer/highlight'),
|
||||
import('@codemirror/view'),
|
||||
])
|
||||
const hl = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.operatorKeyword, t.modifier], color: 'var(--color-warn)' },
|
||||
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--color-ok)' },
|
||||
{ tag: [t.number, t.bool, t.null, t.atom], color: 'var(--color-accent)' },
|
||||
{ tag: [t.comment, t.meta], color: 'var(--color-ink-3)', fontStyle: 'italic' },
|
||||
{ tag: [t.propertyName, t.attributeName], color: 'var(--color-info)' },
|
||||
{ tag: [t.typeName, t.className, t.tagName], color: 'var(--color-accent)' },
|
||||
{ tag: t.heading, color: 'var(--color-ink)', fontWeight: '600' },
|
||||
{ tag: t.link, color: 'var(--color-accent)' },
|
||||
])
|
||||
const theme = EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '12px', backgroundColor: 'transparent' },
|
||||
'.cm-scroller': {
|
||||
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
|
||||
lineHeight: '1.65',
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-gutters': {
|
||||
backgroundColor: 'var(--color-wash)',
|
||||
color: 'var(--color-ink-3)',
|
||||
border: 'none',
|
||||
},
|
||||
'.cm-activeLineGutter': { backgroundColor: 'var(--color-wash)' },
|
||||
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--color-wash) 55%, transparent)' },
|
||||
'&.cm-focused': { outline: 'none' },
|
||||
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-accent) 18%, transparent)',
|
||||
},
|
||||
})
|
||||
// 不带 fallback:作为正式高亮器压过 basicSetup 内置的 defaultHighlightStyle(其为 fallback)
|
||||
return [theme, syntaxHighlighting(hl)]
|
||||
}
|
||||
|
||||
/** 扩展名 → CodeMirror 语言扩展;不在表内按纯文本编辑 */
|
||||
const CM_LANG: Record<string, () => Promise<CmExtension>> = {
|
||||
json: () => import('@codemirror/lang-json').then((m) => m.json()),
|
||||
yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
|
||||
yml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
|
||||
py: () => import('@codemirror/lang-python').then((m) => m.python()),
|
||||
js: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
|
||||
ts: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
|
||||
xml: () => import('@codemirror/lang-xml').then((m) => m.xml()),
|
||||
svg: () => import('@codemirror/lang-xml').then((m) => m.xml()),
|
||||
html: () => import('@codemirror/lang-html').then((m) => m.html()),
|
||||
htm: () => import('@codemirror/lang-html').then((m) => m.html()),
|
||||
css: () => import('@codemirror/lang-css').then((m) => m.css()),
|
||||
md: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
|
||||
sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
|
||||
go: () => import('@codemirror/lang-go').then((m) => m.go()),
|
||||
sh: () => import('@codemirror/legacy-modes/mode/shell').then((m) => streamOf(m.shell)),
|
||||
ini: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
|
||||
conf: () =>
|
||||
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
|
||||
cfg: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
|
||||
env: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
|
||||
properties: () =>
|
||||
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
|
||||
toml: () => import('@codemirror/legacy-modes/mode/toml').then((m) => streamOf(m.toml)),
|
||||
}
|
||||
|
||||
async function streamOf<T>(def: import('@codemirror/language').StreamParser<T>) {
|
||||
const { StreamLanguage } = await import('@codemirror/language')
|
||||
return StreamLanguage.define(def)
|
||||
}
|
||||
|
||||
async function loadLanguage(ext: string): Promise<CmExtension | null> {
|
||||
const load = CM_LANG[ext]
|
||||
if (!load) return null
|
||||
try {
|
||||
return await load()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(v) => {
|
||||
// 外部改写(如美化格式)同步进编辑器;自身输入产生的 emit 值相等,跳过
|
||||
if (view && v !== view.state.doc.toString())
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: v } })
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(d) => {
|
||||
if (view && editableComp && EV)
|
||||
view.dispatch({ effects: editableComp.reconfigure(EV.editable.of(!d)) })
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div v-if="booting" class="flex flex-1 items-center justify-center">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else-if="bootError" class="flex flex-1 items-center justify-center text-xs text-err">
|
||||
{{ bootError }}
|
||||
</div>
|
||||
<div ref="host" class="cm-host min-h-0 flex-1" :class="booting || bootError ? 'hidden' : ''"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cm-host :deep(.cm-editor) {
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import { onBeforeUnmount, computed, ref, watch } from 'vue'
|
||||
|
||||
import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight'
|
||||
|
||||
/** 文本类预览:hljs 高亮;md/svg 支持「渲染 ↔ 代码」双形态 */
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
ext: string
|
||||
truncated?: boolean
|
||||
}>()
|
||||
|
||||
const renderMode = ref<'render' | 'code'>('render')
|
||||
const mdHtml = ref('')
|
||||
const hlHtml = ref('')
|
||||
const svgUrl = ref('')
|
||||
const switchable = computed(() => props.ext === 'md' || props.ext === 'svg')
|
||||
|
||||
watch(() => [props.text, props.ext], () => void refresh(), { immediate: true })
|
||||
onBeforeUnmount(revokeSvg)
|
||||
|
||||
async function refresh() {
|
||||
renderMode.value = 'render'
|
||||
mdHtml.value = ''
|
||||
revokeSvg()
|
||||
if (props.ext === 'svg')
|
||||
svgUrl.value = URL.createObjectURL(new Blob([props.text], { type: 'image/svg+xml' }))
|
||||
if (props.ext === 'md') void renderMd()
|
||||
hlHtml.value = (await highlightSource(props.text, props.ext)) ?? ''
|
||||
}
|
||||
|
||||
function revokeSvg() {
|
||||
if (svgUrl.value) URL.revokeObjectURL(svgUrl.value)
|
||||
svgUrl.value = ''
|
||||
}
|
||||
|
||||
/** markdown 渲染:marked 转 HTML(围栏代码块经 hljs 着色)后 DOMPurify 消毒;库按需加载独立 chunk */
|
||||
async function renderMd() {
|
||||
try {
|
||||
const [{ Marked }, { markedHighlight }, { default: DOMPurify }] = await Promise.all([
|
||||
import('marked'),
|
||||
import('marked-highlight'),
|
||||
import('dompurify'),
|
||||
])
|
||||
const md = new Marked(
|
||||
markedHighlight({
|
||||
async: true,
|
||||
// 未命中语言返回 null,回退 marked 默认转义渲染
|
||||
highlight: (code, lang) => highlightCode(code, lang),
|
||||
}),
|
||||
)
|
||||
mdHtml.value = DOMPurify.sanitize(await md.parse(props.text, { async: true }))
|
||||
} catch {
|
||||
renderMode.value = 'code'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1">
|
||||
<ChoiceChip :active="renderMode === 'render'" @click="renderMode = 'render'">渲染</ChoiceChip>
|
||||
<ChoiceChip :active="renderMode === 'code'" @click="renderMode = 'code'">代码</ChoiceChip>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded bg-white">
|
||||
<img
|
||||
v-if="ext === 'svg' && renderMode === 'render' && svgUrl"
|
||||
:src="svgUrl"
|
||||
alt="svg 预览"
|
||||
class="mx-auto max-h-full object-contain p-4"
|
||||
/>
|
||||
<!-- eslint-disable vue/no-v-html -- mdHtml 经 DOMPurify 消毒;hljs 输出已转义原文本 -->
|
||||
<div
|
||||
v-else-if="ext === 'md' && renderMode === 'render' && mdHtml"
|
||||
class="md-preview code-hl px-4 py-3"
|
||||
v-html="mdHtml"
|
||||
></div>
|
||||
<pre
|
||||
v-else-if="hlHtml"
|
||||
class="code-hl mono m-0 p-3 text-xs leading-relaxed break-all whitespace-pre-wrap"
|
||||
v-html="hlHtml"
|
||||
></pre>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
<pre
|
||||
v-else
|
||||
class="mono m-0 p-3 text-xs leading-relaxed break-all whitespace-pre-wrap"
|
||||
>{{ text }}</pre>
|
||||
</div>
|
||||
<div v-if="truncated" class="mt-1.5 flex-none text-xs text-warn">
|
||||
内容过长已截断,完整内容请下载查看
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* markdown 渲染态的基础排版;内容经 v-html 注入须 :deep 选中。
|
||||
hljs 高亮配色为全局 .code-hl(main.css),与编辑器共用 */
|
||||
.md-preview {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.md-preview :deep(h1),
|
||||
.md-preview :deep(h2),
|
||||
.md-preview :deep(h3),
|
||||
.md-preview :deep(h4) {
|
||||
margin: 0.9em 0 0.4em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.md-preview :deep(h1) {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.md-preview :deep(h2) {
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.md-preview :deep(h3) {
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.md-preview :deep(p),
|
||||
.md-preview :deep(ul),
|
||||
.md-preview :deep(ol),
|
||||
.md-preview :deep(blockquote),
|
||||
.md-preview :deep(pre),
|
||||
.md-preview :deep(table) {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.md-preview :deep(ul),
|
||||
.md-preview :deep(ol) {
|
||||
padding-left: 1.4em;
|
||||
list-style: revert;
|
||||
}
|
||||
.md-preview :deep(code) {
|
||||
background: var(--color-wash);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.35em;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.md-preview :deep(pre) {
|
||||
background: var(--color-wash);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.md-preview :deep(pre code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
.md-preview :deep(blockquote) {
|
||||
border-left: 3px solid var(--color-line);
|
||||
padding-left: 0.8em;
|
||||
color: var(--color-ink-2);
|
||||
}
|
||||
.md-preview :deep(a) {
|
||||
color: var(--color-accent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.md-preview :deep(table) {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.md-preview :deep(th),
|
||||
.md-preview :deep(td) {
|
||||
border: 1px solid var(--color-line);
|
||||
padding: 4px 10px;
|
||||
}
|
||||
.md-preview :deep(img) {
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NModal,
|
||||
NPopconfirm,
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
type DataTableColumns,
|
||||
@@ -19,9 +17,11 @@ import {
|
||||
probeProxy,
|
||||
updateProxy,
|
||||
} from '@/api/proxies'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import ProxyTenantsModal from '@/components/proxy/ProxyTenantsModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
@@ -190,6 +190,13 @@ function renderGeo(row: ProxyInfo) {
|
||||
return h('span', { class: 'text-[13px]' }, row.city ? `${row.country}·${row.city}` : row.country)
|
||||
}
|
||||
|
||||
/** 状态单元格:与地区探测同源 —— 探测经该代理链路发起,失败即代理不可用 */
|
||||
function renderStatus(row: ProxyInfo) {
|
||||
if (!row.geoAt) return h(StatusBadge, { kind: 'check', label: '检测中' })
|
||||
const ok = !!row.country
|
||||
return h(StatusBadge, { kind: ok ? 'run' : 'term', label: ok ? '正常' : '异常' })
|
||||
}
|
||||
|
||||
// 新增 / 批量导入入口由页面标题行触发(ProxyListView),这里暴露打开方法
|
||||
defineExpose({ openCreate, openImport })
|
||||
|
||||
@@ -215,6 +222,7 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
render: (row) => h('span', { class: 'mono text-[12.5px] text-ink-2' }, `${row.host}:${row.port}`),
|
||||
},
|
||||
{ title: '地区', key: 'geo', minWidth: 120, render: renderGeo },
|
||||
{ title: '状态', key: 'status', width: 88, render: renderStatus },
|
||||
{
|
||||
title: '认证',
|
||||
key: 'username',
|
||||
@@ -249,12 +257,11 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openTenants(row) }, () => '关联租户'),
|
||||
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{ onPositiveClick: () => remove(row) },
|
||||
ConfirmPop,
|
||||
{ title: '删除该代理?', content: '删除后不可恢复。', confirmText: '删除', onConfirm: () => remove(row) },
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true, disabled: row.usedBy > 0 }, () => '删除'),
|
||||
default: () => '删除后不可恢复,确认?',
|
||||
},
|
||||
),
|
||||
]),
|
||||
@@ -272,7 +279,7 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
:row-key="(r: ProxyInfo) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储、不回显明文;地区为经代理实测的出口位置(ip-api.com),创建后自动检测;被租户关联的代理不可删除,请先解除关联
|
||||
支持 SOCKS5 / HTTP / HTTPS;被租户关联的代理须先解除关联才可删除
|
||||
</FootNote>
|
||||
|
||||
<ProxyTenantsModal
|
||||
@@ -281,11 +288,12 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
@saved="void proxies.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<NModal
|
||||
<FormModal
|
||||
v-model:show="showForm"
|
||||
preset="card"
|
||||
:title="editing ? '编辑代理' : '新增代理'"
|
||||
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
submit-text="保存"
|
||||
:submitting="saving"
|
||||
@submit="save"
|
||||
>
|
||||
<FormField label="类型">
|
||||
<NRadioGroup v-model:value="form.type" class="w-full" size="small">
|
||||
@@ -327,7 +335,7 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="mb-3.5 flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
|
||||
<div class="flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -345,17 +353,15 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" quaternary @click="showForm = false">取消</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</FormModal>
|
||||
|
||||
<NModal
|
||||
<FormModal
|
||||
v-model:show="showImport"
|
||||
preset="card"
|
||||
title="批量导入代理"
|
||||
:style="{ width: '560px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
submit-text="导入"
|
||||
:submitting="importing"
|
||||
:width="560"
|
||||
@submit="doImport"
|
||||
>
|
||||
<FormField label="代理列表">
|
||||
<NInput
|
||||
@@ -397,10 +403,6 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
<span class="ml-auto flex-none text-[11.5px] text-ink-2">{{ f.reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="importing" @click="doImport">导入</NButton>
|
||||
<NButton size="small" quaternary @click="showImport = false">关闭</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</FormModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setProxyTenants } from '@/api/proxies'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { ProxyInfo } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -16,6 +17,7 @@ const props = defineProps<{ show: boolean; proxy: ProxyInfo | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; saved: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(480)
|
||||
const configs = useAsync(listConfigs, false)
|
||||
const selected = ref<number[]>([])
|
||||
const saving = ref(false)
|
||||
@@ -66,7 +68,8 @@ async function save() {
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`关联租户 · ${proxy?.name ?? ''}`"
|
||||
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<FormField label="关联租户" hint="选中即关联,取消选中即解除;关联后租户全部 SDK 请求经此代理出站">
|
||||
@@ -124,7 +127,7 @@ async function save() {
|
||||
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" quaternary @click="emit('update:show', false)">取消</NButton>
|
||||
<NButton size="small" @click="emit('update:show', false)">取消</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
@@ -114,17 +114,17 @@ function openRepo() {
|
||||
<!-- 运行状态:运行时长随查看自增,资源指标为进程自启动累计口径 -->
|
||||
<div v-if="info?.resources" class="panel">
|
||||
<div
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft px-5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="live-dot"></span>
|
||||
<span class="text-[13px] font-semibold">运行状态</span>
|
||||
<span class="text-sm font-semibold">运行状态</span>
|
||||
</div>
|
||||
<span class="text-xs text-ink-3">
|
||||
自 {{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-5 pt-[22px] pb-5">
|
||||
<div class="px-4.5 pt-[22px] pb-5">
|
||||
<div class="text-xs font-medium text-ink-2">运行时间</div>
|
||||
<div class="mt-1.5 text-[38px] leading-[1.1] font-semibold tracking-[-0.6px]">
|
||||
<template v-for="p in uptimeParts" :key="p.u"
|
||||
@@ -132,9 +132,9 @@ function openRepo() {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mx-5 h-px bg-line-soft"></div>
|
||||
<div class="mx-4.5 h-px bg-line-soft"></div>
|
||||
<div class="grid grid-cols-4 max-md:grid-cols-2">
|
||||
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-5 pt-4 pb-[18px]">
|
||||
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-4.5 pt-4 pb-[18px]">
|
||||
<div class="text-xs font-medium text-ink-2">{{ c.label }}</div>
|
||||
<div class="text-[26px] leading-[1.15] font-semibold tracking-[-0.3px]">
|
||||
<span>{{ c.value }}</span
|
||||
@@ -146,7 +146,7 @@ function openRepo() {
|
||||
</div>
|
||||
|
||||
<!-- GitHub 仓库 -->
|
||||
<div class="panel flex items-center gap-3.5 px-5 py-4">
|
||||
<div class="panel flex items-center gap-3.5 px-4.5 py-4">
|
||||
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
|
||||
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch } from 'naive-ui'
|
||||
import { browserSupportsWebAuthn, startRegistration } from '@simplewebauthn/browser'
|
||||
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { discoverWallets, isUserRejected, personalSign, requestAccount } from '@/composables/useWallet'
|
||||
|
||||
import {
|
||||
activateTotp,
|
||||
beginPasskeyRegister,
|
||||
disableTotp,
|
||||
revokeSessions,
|
||||
finishPasskeyRegister,
|
||||
getCredentials,
|
||||
getOAuthSettings,
|
||||
getOauthAuthorizeUrl,
|
||||
getTotpStatus,
|
||||
getWalletChallenge,
|
||||
listIdentities,
|
||||
listPasskeys,
|
||||
removePasskey,
|
||||
setupTotp,
|
||||
unbindIdentity,
|
||||
updateCredentials,
|
||||
updateOAuthSettings,
|
||||
updatePasswordLogin,
|
||||
verifyWallet,
|
||||
} from '@/api/auth'
|
||||
import ChoiceChip from '@/components/ChoiceChip.vue'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import OauthProviderIcon from '@/components/OauthProviderIcon.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { darkTokens, tokens } from '@/theme/tokens'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
/** TOTP 启用引导弹窗的移动端全屏形态 */
|
||||
const { modalStyle, modalClass } = useMobileModal(360)
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
@@ -37,12 +52,17 @@ function applySession(r: SessionRefresh) {
|
||||
if (r?.token) auth.setSession(r.token, r.expiresAt)
|
||||
}
|
||||
|
||||
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
|
||||
const providerLabels: Record<string, string> = {
|
||||
github: 'GitHub',
|
||||
oidc: 'OIDC 单点登录',
|
||||
wallet: '钱包',
|
||||
}
|
||||
|
||||
/** provider 的展示名:配置显示名 → 默认名 → 原串 */
|
||||
/** provider 的展示名:配置显示名 → 默认名 → 原串;钱包等无自定义名,不得误读 GitHub 的 */
|
||||
function displayNameOf(p: string): string {
|
||||
const cfg = oauthCfg.data.value
|
||||
const custom = p === 'oidc' ? cfg?.oidcDisplayName : cfg?.githubDisplayName
|
||||
const custom =
|
||||
p === 'oidc' ? cfg?.oidcDisplayName : p === 'github' ? cfg?.githubDisplayName : ''
|
||||
return custom || providerLabels[p] || p
|
||||
}
|
||||
|
||||
@@ -114,12 +134,86 @@ async function copyTotpSecret() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 通行密钥(Passkey) ----
|
||||
const passkeys = useAsync(listPasskeys)
|
||||
const showPasskeyModal = ref(false)
|
||||
const passkeyName = ref('')
|
||||
const passkeyBusy = ref(false)
|
||||
/** WebAuthn 仅在 secure context(HTTPS / localhost)可用;不支持时禁用入口并给出原因 */
|
||||
const webauthnSupported = browserSupportsWebAuthn()
|
||||
|
||||
function openPasskeyModal() {
|
||||
passkeyName.value = ''
|
||||
showPasskeyModal.value = true
|
||||
}
|
||||
|
||||
/** 两段式注册:后端 options → 浏览器验证器创建凭据 → 后端校验落库并换发会话 */
|
||||
async function confirmAddPasskey() {
|
||||
if (passkeyBusy.value) return // Enter 连按重入卫:流程进行中忽略再次提交
|
||||
passkeyBusy.value = true
|
||||
try {
|
||||
const { sessionId, options } = await beginPasskeyRegister()
|
||||
const credential = await startRegistration({ optionsJSON: options.publicKey })
|
||||
applySession(await finishPasskeyRegister(sessionId, passkeyName.value.trim(), credential))
|
||||
message.success('通行密钥已添加,登录页可一键免密登录')
|
||||
showPasskeyModal.value = false
|
||||
void passkeys.run()
|
||||
} catch (e) {
|
||||
// 用户在系统弹窗中取消(NotAllowedError)不算错误
|
||||
if (!(e instanceof Error && e.name === 'NotAllowedError'))
|
||||
message.error(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
passkeyBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePasskeyRow(id: number) {
|
||||
try {
|
||||
applySession(await removePasskey(id))
|
||||
message.success('已删除;该通行密钥后续无法登录面板')
|
||||
void passkeys.run()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 外部身份绑定 ----
|
||||
const identities = useAsync(listIdentities)
|
||||
const oauthCfg = useAsync(getOAuthSettings)
|
||||
const binding = ref('')
|
||||
|
||||
// ---- 钱包绑定:注入钱包签名(EIP-4361),身份并入外部身份列表 ----
|
||||
const bindingWallet = ref(false)
|
||||
|
||||
async function bindWallet() {
|
||||
bindingWallet.value = true
|
||||
try {
|
||||
const wallets = await discoverWallets()
|
||||
if (!wallets.length) {
|
||||
message.error('未检测到浏览器钱包扩展(MetaMask / OKX 等)')
|
||||
return
|
||||
}
|
||||
// 绑定场景取首个钱包;多钱包用户可在扩展侧切换默认钱包
|
||||
const w = wallets[0]!
|
||||
const address = await requestAccount(w.provider)
|
||||
const { nonce, message: siwe } = await getWalletChallenge(address, 'bind')
|
||||
const signature = await personalSign(w.provider, siwe, address)
|
||||
applySession(await verifyWallet(nonce, signature))
|
||||
message.success('钱包已绑定,可在登录页用签名登录')
|
||||
void identities.run()
|
||||
} catch (e) {
|
||||
if (!isUserRejected(e)) message.error(e instanceof Error ? e.message : '绑定失败')
|
||||
} finally {
|
||||
bindingWallet.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasIdentity = computed(() => (identities.data.value?.items ?? []).length > 0)
|
||||
const hasPasskey = computed(() => (passkeys.data.value?.items ?? []).length > 0)
|
||||
/** 禁用密码登录的门槛:任一免密方式(通行密钥或外部身份)即可;已禁用时始终可关闭 */
|
||||
const canDisablePassword = computed(() => hasIdentity.value || hasPasskey.value)
|
||||
/** 免密登录卡是否还没有任何条目(空态文案) */
|
||||
const passwordlessEmpty = computed(() => !hasIdentity.value && !hasPasskey.value)
|
||||
|
||||
// ---- 登录凭据:用户名 / 密码修改与密码登录开关 ----
|
||||
const creds = useAsync(getCredentials)
|
||||
@@ -180,12 +274,12 @@ async function saveCredentials() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换密码登录禁用;开启由后端校验至少绑定一个外部身份 */
|
||||
/** 切换密码登录禁用;开启由后端校验至少一种免密方式(通行密钥或外部身份) */
|
||||
async function togglePasswordLogin(disabled: boolean) {
|
||||
togglingPwLogin.value = true
|
||||
try {
|
||||
applySession(await updatePasswordLogin(disabled))
|
||||
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
|
||||
message.success(disabled ? '已禁用密码登录,仅可用通行密钥或外部身份登录' : '已恢复密码登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
@@ -194,17 +288,7 @@ async function togglePasswordLogin(disabled: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 已配置 clientID 的 provider 才显示绑定入口 */
|
||||
const bindableProviders = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const out: string[] = []
|
||||
if (cfg.oidcClientId) out.push('oidc')
|
||||
if (cfg.githubClientId) out.push('github')
|
||||
return out
|
||||
})
|
||||
|
||||
/** 已绑定的 provider 集合;绑定入口按钮据此禁用,解绑后自动恢复 */
|
||||
/** 已绑定的 provider 集合;表格「绑定」按钮据此置灰,解绑后自动恢复 */
|
||||
const boundProviders = computed(
|
||||
() => new Set((identities.data.value?.items ?? []).map((it) => it.provider)),
|
||||
)
|
||||
@@ -230,21 +314,6 @@ async function removeIdentity(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 撤销全部会话:令牌版本递增,除本会话(换新)外全部立即失效 ----
|
||||
const revoking = ref(false)
|
||||
|
||||
async function revokeAllSessions() {
|
||||
revoking.value = true
|
||||
try {
|
||||
applySession(await revokeSessions())
|
||||
message.success('已撤销全部会话;其他设备须重新登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 登录方式配置(OAuth provider):表格 + 弹窗增改;禁用仅隐藏登录入口,删除清全部配置 ----
|
||||
type ProviderType = 'github' | 'oidc'
|
||||
|
||||
@@ -269,13 +338,20 @@ const pForm = reactive({
|
||||
const providerRows = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
if (!cfg) return []
|
||||
const rows: { provider: ProviderType; name: string; clientId: string; disabled: boolean }[] = []
|
||||
const rows: {
|
||||
provider: ProviderType
|
||||
name: string
|
||||
clientId: string
|
||||
disabled: boolean
|
||||
ready: boolean
|
||||
}[] = []
|
||||
if (cfg.githubClientId)
|
||||
rows.push({
|
||||
provider: 'github',
|
||||
name: displayNameOf('github'),
|
||||
clientId: cfg.githubClientId,
|
||||
disabled: cfg.githubDisabled,
|
||||
ready: cfg.githubSecretSet,
|
||||
})
|
||||
if (cfg.oidcClientId)
|
||||
rows.push({
|
||||
@@ -283,6 +359,7 @@ const providerRows = computed(() => {
|
||||
name: displayNameOf('oidc'),
|
||||
clientId: cfg.oidcClientId,
|
||||
disabled: cfg.oidcDisabled,
|
||||
ready: cfg.oidcSecretSet && Boolean(cfg.oidcIssuer),
|
||||
})
|
||||
return rows
|
||||
})
|
||||
@@ -308,32 +385,20 @@ function openEditProvider(p: ProviderType) {
|
||||
showProviderModal.value = true
|
||||
}
|
||||
|
||||
const pFormValid = computed(
|
||||
() => Boolean(pForm.clientId.trim()) && (pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())),
|
||||
)
|
||||
|
||||
const secretConfigured = computed(() => {
|
||||
const cfg = oauthCfg.data.value
|
||||
return pForm.provider === 'oidc' ? !!cfg?.oidcSecretSet : !!cfg?.githubSecretSet
|
||||
})
|
||||
|
||||
/** 服务端接口为两 provider 整体保存:以现值为底、只覆写 patch 内字段,防误清 */
|
||||
function oauthPayloadWith(patch: Partial<UpdateOAuthRequest>): UpdateOAuthRequest {
|
||||
const cfg = oauthCfg.data.value
|
||||
return {
|
||||
oidcIssuer: cfg?.oidcIssuer ?? '',
|
||||
oidcClientId: cfg?.oidcClientId ?? '',
|
||||
oidcDisplayName: cfg?.oidcDisplayName ?? '',
|
||||
oidcDisabled: cfg?.oidcDisabled ?? false,
|
||||
githubClientId: cfg?.githubClientId ?? '',
|
||||
githubDisplayName: cfg?.githubDisplayName ?? '',
|
||||
githubDisabled: cfg?.githubDisabled ?? false,
|
||||
...patch,
|
||||
}
|
||||
}
|
||||
const pFormValid = computed(
|
||||
() =>
|
||||
Boolean(pForm.clientId.trim()) &&
|
||||
(pForm.provider !== 'oidc' || Boolean(pForm.issuer.trim())) &&
|
||||
(secretConfigured.value || Boolean(pForm.secret)),
|
||||
)
|
||||
|
||||
/** 表单字段折叠为目标 provider 的 patch;secret 留空沿用 */
|
||||
function providerFormPatch(): Partial<UpdateOAuthRequest> {
|
||||
/** 表单折叠为目标 provider 的字段补丁;secret 留空沿用,另一 provider 完全不出现 */
|
||||
function providerFormPatch(): UpdateOAuthRequest {
|
||||
const name = pForm.displayName.trim()
|
||||
const id = pForm.clientId.trim()
|
||||
if (pForm.provider === 'oidc')
|
||||
@@ -353,7 +418,7 @@ function providerFormPatch(): Partial<UpdateOAuthRequest> {
|
||||
async function saveProvider() {
|
||||
savingOauth.value = true
|
||||
try {
|
||||
await updateOAuthSettings(oauthPayloadWith(providerFormPatch()))
|
||||
await updateOAuthSettings(providerFormPatch())
|
||||
message.success(`登录方式「${pForm.displayName.trim() || providerLabels[pForm.provider]}」已保存`)
|
||||
showProviderModal.value = false
|
||||
void oauthCfg.run({ silent: true })
|
||||
@@ -369,7 +434,7 @@ async function toggleProviderDisabled(p: ProviderType, disabled: boolean) {
|
||||
savingOauth.value = true
|
||||
try {
|
||||
const patch = p === 'oidc' ? { oidcDisabled: disabled } : { githubDisabled: disabled }
|
||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
||||
await updateOAuthSettings(patch)
|
||||
message.success(`「${displayNameOf(p)}」已${disabled ? '禁用,登录页不再展示' : '启用'}`)
|
||||
void oauthCfg.run({ silent: true })
|
||||
} catch (e) {
|
||||
@@ -388,7 +453,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
p === 'oidc'
|
||||
? { oidcIssuer: '', oidcClientId: '', oidcClientSecret: '', oidcDisplayName: '', oidcDisabled: false }
|
||||
: { githubClientId: '', githubClientSecret: '', githubDisplayName: '', githubDisabled: false }
|
||||
await updateOAuthSettings(oauthPayloadWith(patch))
|
||||
await updateOAuthSettings(patch)
|
||||
message.success(`登录方式「${name}」已删除`)
|
||||
void oauthCfg.run({ silent: true })
|
||||
} catch (e) {
|
||||
@@ -400,14 +465,15 @@ async function deleteProvider(p: ProviderType) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 账号安全:两步验证 + 外部身份 -->
|
||||
<!-- 密码登录 / 免密登录:双栏卡(窄屏纵排),方案B 布局 -->
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">账号安全</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">两步验证与外部身份</div>
|
||||
<div class="text-sm font-semibold">密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">登录凭据与两步验证</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2.5 py-[3px] text-[11.5px] text-ink-2"
|
||||
@@ -422,11 +488,19 @@ async function deleteProvider(p: ProviderType) {
|
||||
<div class="px-4.5 py-1">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
|
||||
<div class="text-[13px] font-medium">登录凭据</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password 等
|
||||
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">密码登录时额外输入验证器动态码</div>
|
||||
</div>
|
||||
<NSpin v-if="totp.loading.value" size="small" />
|
||||
<NButton
|
||||
v-else-if="totp.data.value?.enabled"
|
||||
@@ -442,36 +516,14 @@ async function deleteProvider(p: ProviderType) {
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">登录凭据</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
用户名 {{ creds.data.value?.username ?? '…' }};修改用户名或密码后需重新登录
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">撤销全部会话</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
怀疑令牌泄露时使用;其他设备与旧令牌立即失效,本会话自动换新
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" type="error" quaternary :loading="revoking" @click="revokeAllSessions">
|
||||
撤销
|
||||
</NButton>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">禁用密码登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
{{
|
||||
hasIdentity || creds.data.value?.passwordLoginDisabled
|
||||
? '开启后仅外部身份可登录;关闭随时恢复密码登录'
|
||||
: '至少绑定一个外部身份(OAuth2)后才能开启'
|
||||
canDisablePassword || creds.data.value?.passwordLoginDisabled
|
||||
? '开启后仅可用通行密钥或外部身份登录;关闭随时恢复'
|
||||
: '需至少添加一枚通行密钥或绑定一个外部身份'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -479,48 +531,131 @@ async function deleteProvider(p: ProviderType) {
|
||||
:value="creds.data.value?.passwordLoginDisabled ?? false"
|
||||
size="small"
|
||||
:loading="togglingPwLogin"
|
||||
:disabled="!hasIdentity && !creds.data.value?.passwordLoginDisabled"
|
||||
:disabled="!canDisablePassword && !creds.data.value?.passwordLoginDisabled"
|
||||
@update:value="togglePasswordLogin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">免密登录</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">通行密钥、OAuth 与钱包;解绑受防自锁保护</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<NButton
|
||||
size="tiny"
|
||||
type="primary"
|
||||
:loading="passkeyBusy"
|
||||
:disabled="!webauthnSupported"
|
||||
@click="openPasskeyModal"
|
||||
>
|
||||
添加通行密钥
|
||||
</NButton>
|
||||
<NButton size="tiny" :loading="bindingWallet" @click="bindWallet">绑定钱包</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5 py-1">
|
||||
<div
|
||||
v-for="pk in passkeys.data.value?.items ?? []"
|
||||
:key="'pk-' + pk.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M12 11c0 3.5-.6 6.3-1.7 8.6" />
|
||||
<path d="M8.5 11a3.5 3.5 0 0 1 7 0c0 2.2-.2 4.2-.7 6" />
|
||||
<path d="M5.4 9.1a7 7 0 0 1 13.2 1.9c0 1.4-.1 2.7-.3 4" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-[13px] font-medium">通行密钥 · {{ pk.name }}</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
添加于 {{ fmtTime(pk.createdAt) }}<template v-if="pk.lastUsedAt">
|
||||
· 最近使用 {{ fmtTime(pk.lastUsedAt) }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
:title="`删除通行密钥「${pk.name}」?`"
|
||||
content="删除后该密钥无法再登录面板;设备侧的凭据条目需自行清理。"
|
||||
kind="plain"
|
||||
confirm-text="删除"
|
||||
:on-confirm="() => removePasskeyRow(pk.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="it in identities.data.value?.items ?? []"
|
||||
:key="it.id"
|
||||
class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
v-if="it.provider === 'wallet'"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 7H5a2 2 0 0 1-2-2v12a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1Z" />
|
||||
<path d="M3 5a2 2 0 0 1 2-2h12v4" />
|
||||
</svg>
|
||||
<OauthProviderIcon v-else :provider="it.provider" class="!h-3.5 !w-3.5" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">
|
||||
<div class="truncate text-[13px] font-medium">
|
||||
{{ displayNameOf(it.provider) }} · {{ it.display }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
|
||||
</div>
|
||||
<NPopconfirm @positive-click="removeIdentity(it.id)">
|
||||
</div>
|
||||
<ConfirmPop
|
||||
:title="`解绑 ${displayNameOf(it.provider)} 身份?`"
|
||||
content="解绑后该身份无法再登录面板。"
|
||||
kind="plain"
|
||||
confirm-text="解绑"
|
||||
:on-confirm="() => removeIdentity(it.id)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>解绑</NButton>
|
||||
</template>
|
||||
解绑后该身份无法再登录面板,确认?
|
||||
</NPopconfirm>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="bindableProviders.length"
|
||||
class="flex flex-wrap items-center gap-2 border-b border-line-soft py-2.5"
|
||||
>
|
||||
<NButton
|
||||
v-for="p in bindableProviders"
|
||||
:key="p"
|
||||
size="small"
|
||||
:loading="binding === p"
|
||||
:disabled="boundProviders.has(p)"
|
||||
@click="bindProvider(p)"
|
||||
>
|
||||
{{ boundProviders.has(p) ? '已绑定' : '绑定' }} {{ displayNameOf(p) }}
|
||||
</NButton>
|
||||
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
|
||||
<div v-if="passwordlessEmpty" class="border-b border-line-soft py-2.5 text-xs text-ink-3">
|
||||
尚未添加任何免密登录方式;添加后可在登录页免密码一键登录
|
||||
</div>
|
||||
<div v-if="!webauthnSupported" class="pt-2.5 text-xs text-warn">
|
||||
当前环境不支持通行密钥:需通过 HTTPS(或 localhost)访问面板
|
||||
</div>
|
||||
<div class="py-2.5 text-xs text-ink-3">
|
||||
外部身份绑定后可直接登录;未绑定的外部身份一律无法登录(不开放注册)
|
||||
OAuth 身份在下方「登录方式配置」操作列发起绑定;未绑定的身份无法登录(不开放注册)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,7 +667,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">登录方式配置</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">配置后登录页与上方绑定入口即出现对应方式</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">OAuth 提供方;启用后登录页出现入口,在操作列绑定到当前账号</div>
|
||||
</div>
|
||||
<NButton size="tiny" @click="openCreateProvider">新增登录方式</NButton>
|
||||
</div>
|
||||
@@ -544,7 +679,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">类型</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">Client ID</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">状态</th>
|
||||
<th class="w-40 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
<th class="w-52 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -560,13 +695,26 @@ async function deleteProvider(p: ProviderType) {
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="row.disabled ? 'bg-ink-3' : 'bg-ok'" />
|
||||
{{ row.disabled ? '已禁用' : '启用中' }}
|
||||
<span
|
||||
class="h-1.5 w-1.5 rounded-full"
|
||||
:class="!row.ready ? 'bg-warn' : row.disabled ? 'bg-ink-3' : 'bg-ok'"
|
||||
/>
|
||||
{{ !row.ready ? '配置不完整' : row.disabled ? '已禁用' : '启用中' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
:loading="binding === row.provider"
|
||||
:disabled="!row.ready || boundProviders.has(row.provider)"
|
||||
@click="bindProvider(row.provider)"
|
||||
>
|
||||
绑定
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="openEditProvider(row.provider)">编辑</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
@@ -575,12 +723,16 @@ async function deleteProvider(p: ProviderType) {
|
||||
>
|
||||
{{ row.disabled ? '启用' : '禁用' }}
|
||||
</NButton>
|
||||
<NPopconfirm @positive-click="deleteProvider(row.provider)">
|
||||
<ConfirmPop
|
||||
title="删除该登录方式?"
|
||||
content="将清空全部配置(含 Secret),不可恢复。"
|
||||
confirm-text="删除"
|
||||
:on-confirm="() => deleteProvider(row.provider)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary>删除</NButton>
|
||||
</template>
|
||||
删除将清空该方式全部配置(含 Secret),不可恢复;确认?
|
||||
</NPopconfirm>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -590,9 +742,8 @@ async function deleteProvider(p: ProviderType) {
|
||||
尚未配置任何登录方式;点击右上「新增登录方式」配置 GitHub 或 OIDC
|
||||
</div>
|
||||
<FootNote>
|
||||
在 IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
|
||||
}}/api/v1/auth/oauth/<github|oidc>/callback;secret 加密存储不回显 ·
|
||||
需先在「网络与地址」保存面板地址
|
||||
在 IdP / GitHub 应用中填写的授权回调地址:
|
||||
{{ callbackBase }}/api/v1/auth/oauth/<github|oidc>/callback;已绑定的身份在上方「免密登录」中解绑
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
@@ -609,22 +760,15 @@ async function deleteProvider(p: ProviderType) {
|
||||
>
|
||||
<FormField label="类型" required>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
<ChoiceChip
|
||||
v-for="t in PROVIDER_TYPES"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="rounded-md border px-3 py-1.5 text-xs"
|
||||
:class="[
|
||||
pForm.provider === t.value
|
||||
? 'border-accent bg-accent/10 font-semibold text-accent'
|
||||
: 'border-line bg-white text-ink-2',
|
||||
editingProvider ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-ink-3',
|
||||
]"
|
||||
:active="pForm.provider === t.value"
|
||||
:disabled="!!editingProvider"
|
||||
@click="pForm.provider = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</ChoiceChip>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="显示名称" :hint="`登录页按钮与绑定入口的名称;留空用默认「${providerLabels[pForm.provider]}」`">
|
||||
@@ -655,6 +799,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Client Secret"
|
||||
required
|
||||
:hint="secretConfigured ? '已配置,留空沿用;服务端加密存储' : '服务端加密存储,保存后不回显'"
|
||||
>
|
||||
<NInput v-model:value="pForm.secret" type="password" show-password-on="click" />
|
||||
@@ -706,6 +851,29 @@ async function deleteProvider(p: ProviderType) {
|
||||
</FormField>
|
||||
</FormModal>
|
||||
|
||||
<!-- 添加通行密钥弹窗:命名后唤起浏览器验证器 -->
|
||||
<FormModal
|
||||
:show="showPasskeyModal"
|
||||
title="添加通行密钥"
|
||||
:width="380"
|
||||
:submitting="passkeyBusy"
|
||||
submit-text="继续"
|
||||
@update:show="showPasskeyModal = $event"
|
||||
@submit="confirmAddPasskey"
|
||||
>
|
||||
<FormField label="名称" hint="便于区分设备,如「MacBook Touch ID」;留空用默认名">
|
||||
<NInput
|
||||
v-model:value="passkeyName"
|
||||
:maxlength="64"
|
||||
placeholder="通行密钥"
|
||||
@keyup.enter="confirmAddPasskey"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="text-xs text-ink-3">
|
||||
点击「继续」后按浏览器提示完成验证(生物识别 / PIN / 安全钥匙)
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<!-- 停用两步验证弹窗 -->
|
||||
<FormModal
|
||||
:show="showTotpDisable"
|
||||
@@ -736,7 +904,8 @@ async function deleteProvider(p: ProviderType) {
|
||||
preset="card"
|
||||
title="启用两步验证"
|
||||
closable
|
||||
:style="{ width: '360px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<div v-if="totpPending" class="flex flex-col items-center gap-3">
|
||||
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
|
||||
@@ -745,7 +914,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
:value="totpPending.otpauthUri"
|
||||
:size="160"
|
||||
:padding="0"
|
||||
:color="app.dark ? '#f5f4ed' : '#141413'"
|
||||
:color="app.dark ? darkTokens.ink : tokens.ink"
|
||||
background-color="transparent"
|
||||
error-correction-level="M"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NInputNumber, NSpin, NSwitch } from 'naive-ui'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
|
||||
import { getAiSettings, listAiBlacklist, removeAiBlacklist, updateAiSettings } from '@/api/aigateway'
|
||||
import BlacklistAddModal from '@/components/settings/BlacklistAddModal.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiSettings } from '@/types/api'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const settings = useAsync(getAiSettings)
|
||||
const blacklist = useAsync(listAiBlacklist)
|
||||
|
||||
/** 本地表单镜像:开关即时提交,数字输入失焦/回车提交 */
|
||||
const form = reactive<AiSettings>({
|
||||
filterDeprecated: false,
|
||||
streamGuardEnabled: true,
|
||||
streamGuardKB: 60,
|
||||
grokWebSearch: true,
|
||||
grokXSearch: true,
|
||||
upstreamWaitSeconds: 300,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => settings.data.value,
|
||||
(v) => {
|
||||
if (v) Object.assign(form, v)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
/** 全量 PUT;失败回滚到服务端最新值 */
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
Object.assign(form, await updateAiSettings({ ...form }))
|
||||
toast.success('已保存,即时生效')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
void settings.run({ silent: true })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setField<K extends keyof AiSettings>(key: K, value: AiSettings[K]) {
|
||||
form[key] = value
|
||||
void save()
|
||||
}
|
||||
|
||||
/** 阈值失焦/回车提交;NInputNumber 已按 min/max 收敛 */
|
||||
function commitGuardKB(v: number | null) {
|
||||
if (v == null || v === form.streamGuardKB) return
|
||||
setField('streamGuardKB', v)
|
||||
}
|
||||
|
||||
/** 上游无响应预算失焦/回车提交 */
|
||||
function commitUpstreamWait(v: number | null) {
|
||||
if (v == null || v === form.upstreamWaitSeconds) return
|
||||
setField('upstreamWaitSeconds', v)
|
||||
}
|
||||
|
||||
const removing = ref(0)
|
||||
async function doRemove(id: number, name: string) {
|
||||
removing.value = id
|
||||
try {
|
||||
await removeAiBlacklist(id)
|
||||
toast.success(`已移出黑名单:${name},重新同步渠道后恢复`)
|
||||
void blacklist.run({ silent: true })
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '移出失败')
|
||||
} finally {
|
||||
removing.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const showAdd = ref(false)
|
||||
function onBlacklistChanged() {
|
||||
void blacklist.run({ silent: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="settings.loading.value && !settings.data.value" class="panel flex max-w-[880px] items-center justify-center py-20">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else class="flex max-w-[880px] flex-col gap-4">
|
||||
<!-- Responses 直通防护 -->
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">Responses 直通</div>
|
||||
</div>
|
||||
<div class="px-4.5">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">流式保险丝:instructions + tools 阈值</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
系统提示与工具定义合计超过阈值的流式请求,预防性改走非流式上游并合成
|
||||
SSE:丢增量输出,保会话不中断
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-3">
|
||||
<NInputNumber
|
||||
:value="form.streamGuardKB"
|
||||
:min="1"
|
||||
:max="1024"
|
||||
:show-button="false"
|
||||
:disabled="!form.streamGuardEnabled"
|
||||
size="small"
|
||||
class="w-24"
|
||||
:update-value-on-input="false"
|
||||
@update:value="commitGuardKB"
|
||||
>
|
||||
<template #suffix><span class="text-xs text-ink-3">KB</span></template>
|
||||
</NInputNumber>
|
||||
<NSwitch
|
||||
:value="form.streamGuardEnabled"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@update:value="(v: boolean) => setField('streamGuardEnabled', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 py-3.5">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">上游无响应预算</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
非流式为单次尝试总超时,流式为等待响应头上限,响应头到达后不限制流时长;
|
||||
multi-agent / 搜索类模型单次调用可达 3 分钟,建议 ≥300 秒
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-3">
|
||||
<NInputNumber
|
||||
:value="form.upstreamWaitSeconds"
|
||||
:min="30"
|
||||
:max="900"
|
||||
:show-button="false"
|
||||
size="small"
|
||||
class="w-24"
|
||||
:update-value-on-input="false"
|
||||
@update:value="commitUpstreamWait"
|
||||
>
|
||||
<template #suffix><span class="text-xs text-ink-3">秒</span></template>
|
||||
</NInputNumber>
|
||||
<!-- 隐形开关与上一行等宽占位,保证两行输入框网格对齐 -->
|
||||
<NSwitch class="invisible" size="small" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
仅作用于 Responses 直通面;Chat / Messages 已有断流自动重做兜底
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- grok 服务端搜索工具 -->
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">grok 服务端搜索工具</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
仅对 <span class="mono">xai.</span> 前缀模型的对话请求生效
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-3">
|
||||
<div class="text-[13px] font-medium">默认开启 web_search</div>
|
||||
<NSwitch
|
||||
:value="form.grokWebSearch"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@update:value="(v: boolean) => setField('grokWebSearch', v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 py-3">
|
||||
<div class="text-[13px] font-medium">默认开启 x_search</div>
|
||||
<NSwitch
|
||||
:value="form.grokXSearch"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@update:value="(v: boolean) => setField('grokXSearch', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型治理(自 AI 网关页迁入) -->
|
||||
<div class="panel">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">模型治理</div>
|
||||
</div>
|
||||
<div class="px-4.5 pb-4">
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-3.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>
|
||||
<NSwitch
|
||||
:value="form.filterDeprecated"
|
||||
size="small"
|
||||
:loading="saving"
|
||||
@update:value="(v: boolean) => setField('filterDeprecated', v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 pt-3.5 pb-1">
|
||||
<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" quaternary type="primary" @click="showAdd = true">+ 添加模型</NButton>
|
||||
</div>
|
||||
<table class="mt-1.5 w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-y border-line-soft text-left text-xs font-medium text-ink-3">
|
||||
<th class="w-[52%] px-3 py-2">模型</th>
|
||||
<th class="px-3 py-2">加入时间</th>
|
||||
<th class="w-22 px-3 py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="b in blacklist.data.value ?? []"
|
||||
:key="b.id"
|
||||
class="border-b border-line-soft last:border-b-0"
|
||||
>
|
||||
<td class="px-3 py-2"><span class="mono text-[13px]">{{ b.name }}</span></td>
|
||||
<td class="px-3 py-2 text-xs text-ink-3">{{ fmtTime(b.createdAt) }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:loading="removing === b.id"
|
||||
@click="doRemove(b.id, b.name)"
|
||||
>
|
||||
移出
|
||||
</NButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!(blacklist.data.value ?? []).length">
|
||||
<td colspan="3" class="px-3 py-6 text-center text-xs text-ink-3">
|
||||
黑名单为空;点右上「+ 添加模型」从聚合模型目录选择
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BlacklistAddModal
|
||||
v-model:show="showAdd"
|
||||
:blacklisted="(blacklist.data.value ?? []).map((b) => b.name)"
|
||||
@changed="onBlacklistChanged"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { addAiBlacklist, listAiModelCatalog } from '@/api/aigateway'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiCatalogModel } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; blacklisted: string[] }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const rows = ref<AiCatalogModel[]>([])
|
||||
const loading = ref(false)
|
||||
const adding = ref('')
|
||||
const filter = ref('')
|
||||
|
||||
const CAP_ORDER = ['CHAT', 'EMBEDDING', 'RERANK', 'TTS']
|
||||
const CAP_LABEL: Record<string, string> = { CHAT: '对话', EMBEDDING: '向量', RERANK: '重排', TTS: '语音' }
|
||||
|
||||
const bannedSet = computed(() => new Set(props.blacklisted))
|
||||
|
||||
/** 按能力分组 + 关键字过滤,与渠道「模型列表」弹窗同构 */
|
||||
const groups = computed(() => {
|
||||
const kw = filter.value.trim().toLowerCase()
|
||||
const buckets = new Map<string, AiCatalogModel[]>()
|
||||
for (const r of rows.value) {
|
||||
if (kw && !r.name.toLowerCase().includes(kw)) continue
|
||||
buckets.set(r.capability, [...(buckets.get(r.capability) ?? []), r])
|
||||
}
|
||||
const order = [...CAP_ORDER, ...[...buckets.keys()].filter((c) => !CAP_ORDER.includes(c))]
|
||||
return order
|
||||
.filter((cap) => buckets.has(cap))
|
||||
.map((cap) => ({ cap, label: CAP_LABEL[cap] ?? cap, items: buckets.get(cap)! }))
|
||||
})
|
||||
|
||||
const shownCount = computed(() => groups.value.reduce((n, g) => n + g.items.length, 0))
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
filter.value = ''
|
||||
void reload()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function reload() {
|
||||
loading.value = true
|
||||
try {
|
||||
rows.value = await listAiModelCatalog()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹窗即添加场景,意图明确不做二次确认;可在黑名单中移出恢复 */
|
||||
async function doBan(name: string) {
|
||||
if (adding.value) return
|
||||
adding.value = name
|
||||
try {
|
||||
await addAiBlacklist(name)
|
||||
toast.success(`已拉黑:${name}`)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '拉黑失败')
|
||||
} finally {
|
||||
adding.value = ''
|
||||
}
|
||||
}
|
||||
</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">添加到模型黑名单</div>
|
||||
<div class="mt-1 text-xs font-normal text-ink-3">聚合全部渠道的可用模型</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<NInput v-model:value="filter" size="small" placeholder="过滤模型名…" clearable class="min-w-0 flex-1" />
|
||||
<span class="flex-none text-xs whitespace-nowrap text-ink-3">
|
||||
{{ filter ? `${shownCount} / ${rows.length}` : `${rows.length} 个模型` }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="-mx-2 max-h-[58vh] overflow-y-auto">
|
||||
<div v-if="loading" class="py-10 text-center text-xs text-ink-3">加载中…</div>
|
||||
<template v-else>
|
||||
<template v-for="g in groups" :key="g.cap">
|
||||
<div class="px-3 pt-3 pb-1 text-[11.5px] font-semibold tracking-wide text-ink-3">
|
||||
{{ g.label }} <span class="font-normal">· {{ g.items.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="r in g.items"
|
||||
:key="r.name"
|
||||
class="group flex items-center gap-2 rounded-lg px-3 py-[5px] hover:bg-row-hover"
|
||||
>
|
||||
<span class="mono min-w-0 truncate text-[13px]" :class="bannedSet.has(r.name) ? 'text-ink-3/60' : ''">
|
||||
{{ r.name }}
|
||||
</span>
|
||||
<span class="min-w-0 flex-1" />
|
||||
<span v-if="bannedSet.has(r.name)" class="flex-none px-1.5 text-xs text-ink-3/50">已拉黑</span>
|
||||
<button
|
||||
v-else
|
||||
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"
|
||||
:disabled="!!adding"
|
||||
@click="doBan(r.name)"
|
||||
>
|
||||
{{ adding === r.name ? '拉黑中…' : '拉黑' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!shownCount" class="py-10 text-center text-xs text-ink-3">
|
||||
{{ rows.length ? '无匹配模型' : '暂无模型缓存,先在 AI 网关同步渠道模型' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 border-t border-line-soft pt-2.5 text-[11.5px] leading-relaxed text-ink-3">
|
||||
<span class="font-medium text-ink-2">拉黑</span>:全局生效,从全部渠道剔除 ·
|
||||
移出后重新同步渠道恢复 · 已拉黑的模型置灰
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
@@ -191,7 +191,6 @@ async function sendTest() {
|
||||
:key="p.label"
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="primary"
|
||||
@click="form.bodyTemplate = p.tpl"
|
||||
>
|
||||
{{ p.label }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NButton, NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { testNotifyTemplate, updateNotifyTemplate } from '@/api/settings'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { NotifyTemplateItem } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -20,6 +21,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(600)
|
||||
const draft = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
@@ -104,7 +106,8 @@ async function sendTest() {
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
closable
|
||||
:style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cronText, parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/c
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
import type { Task } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ task: Task; cfgAlias?: string }>()
|
||||
const props = defineProps<{ task: Task; cfgAlias?: string; busy?: boolean }>()
|
||||
const emit = defineEmits<{ run: []; toggle: []; edit: []; remove: [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
@@ -79,7 +79,7 @@ function goDetail() {
|
||||
<div class="flex flex-none items-center gap-0.5" @click.stop>
|
||||
<NTooltip v-if="task.status !== 'succeeded'">
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="emit('toggle')">
|
||||
<NButton size="small" quaternary :disabled="busy" @click="emit('toggle')">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -98,7 +98,7 @@ function goDetail() {
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="emit('run')">
|
||||
<NButton size="small" quaternary :disabled="busy" @click="emit('run')">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -116,7 +116,7 @@ function goDetail() {
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<template #trigger>
|
||||
<NButton size="small" quaternary circle @click="goDetail">
|
||||
<NButton size="small" quaternary @click="goDetail">
|
||||
<svg
|
||||
class="h-[15px] w-[15px]"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -132,7 +132,7 @@ function goDetail() {
|
||||
详情与事件流
|
||||
</NTooltip>
|
||||
<NDropdown v-if="moreOptions.length" :options="moreOptions" trigger="click" @select="onMore">
|
||||
<NButton size="small" quaternary circle>
|
||||
<NButton size="small" quaternary>
|
||||
<svg class="h-[15px] w-[15px]" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="1.6" />
|
||||
<circle cx="12" cy="12" r="1.6" />
|
||||
|
||||
@@ -70,7 +70,9 @@ watch(
|
||||
if (t.type === 'snatch') {
|
||||
const inst = (p.instance ?? {}) as Record<string, unknown>
|
||||
form.snatchCfgId = typeof p.ociConfigId === 'number' ? p.ociConfigId : null
|
||||
form.count = typeof p.count === 'number' ? p.count : 1
|
||||
// 表单编辑的是目标台数:优先 totalCount,旧任务缺省回退剩余台数
|
||||
const target = typeof p.totalCount === 'number' ? p.totalCount : p.count
|
||||
form.count = typeof target === 'number' ? target : 1
|
||||
form.displayName = String(inst.displayName ?? '')
|
||||
form.snatchRegion = typeof inst.region === 'string' ? inst.region : ''
|
||||
form.snatchCompartmentId = typeof inst.compartmentId === 'string' ? inst.compartmentId : ''
|
||||
|
||||
@@ -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>
|
||||
@@ -1,20 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NDataTable, NModal, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue'
|
||||
import { NButton, NDataTable, NInput, NInputGroup, NInputGroupLabel, NModal, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, onMounted, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { eventLabel } from '@/composables/useEventLabel'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { AuditEvent } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number }>()
|
||||
const message = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(720)
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
@@ -23,23 +28,78 @@ const rows = ref<AuditEvent[]>([])
|
||||
// 服务端分窗回溯游标:空且 exhausted 表示已到 365 天保留期尽头
|
||||
const cursor = ref('')
|
||||
const exhausted = ref(false)
|
||||
// 服务端全文检索关键字;变化即重置信息流
|
||||
const q = ref('')
|
||||
// 已完整回溯到的时刻(比它更新的时段已扫完),检索命中稀疏时给用户方位感
|
||||
const scannedThrough = ref('')
|
||||
// 信息流代次:刷新/换关键字自增,用于丢弃仍在途的过期响应
|
||||
let gen = 0
|
||||
let qTimer: ReturnType<typeof setTimeout> | undefined
|
||||
// 当前信息流对应的关键字;回车/防抖只在关键字实际变化时才重查,避免重复请求
|
||||
let loadedQ: string | null = null
|
||||
// 连续自动补批计数:搜索命中稀疏时封顶,不能无人值守扫完整个保留期
|
||||
let autoFills = 0
|
||||
const maxAutoFills = 2
|
||||
|
||||
// 关键字防抖:停止输入 500ms 后按新关键字重查;回车/清空即时生效
|
||||
watch(q, () => {
|
||||
clearTimeout(qTimer)
|
||||
if (q.value.trim() === loadedQ) return
|
||||
qTimer = setTimeout(() => void load(), 500)
|
||||
})
|
||||
|
||||
/** 回车/清空的即时提交:关键字未变则不发起重复搜索 */
|
||||
function submitSearch() {
|
||||
clearTimeout(qTimer)
|
||||
if (q.value.trim() !== loadedQ) void load()
|
||||
}
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(rows.value.length / pagination.pageSize)))
|
||||
const hasMore = computed(() => cursor.value !== '' && !exhausted.value)
|
||||
|
||||
/** 回溯进度 = min(游标窗上界, 已加载最旧事件):窗内续翻时游标上界还停在窗口右缘,
|
||||
* 直接展示会显示成当前时刻;两者取更早才反映真实扫描位置 */
|
||||
const scanProgress = computed(() => {
|
||||
const oldest = rows.value.length ? (rows.value[rows.value.length - 1].eventTime ?? '') : ''
|
||||
if (!scannedThrough.value || !oldest) return scannedThrough.value || oldest
|
||||
return scannedThrough.value < oldest ? scannedThrough.value : oldest
|
||||
})
|
||||
|
||||
/** 受控分页:字面量对象会在重渲染时被重建导致页大小切换失效 */
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
prefix: () =>
|
||||
`已加载 ${rows.value.length} 条 · ` +
|
||||
(loadingMore.value ? '正在加载更早…' : exhausted.value ? '已到 365 天保留期尽头' : '翻到末页自动加载更早'),
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
/** 状态行:加载中/尽头给文案;停驻且还有更早数据时给「加载更早」按钮,
|
||||
* 自动补批封顶后由用户显式接力,不会无人值守扫完整个保留期 */
|
||||
prefix: () => {
|
||||
const parts = [`已加载 ${rows.value.length} 条`]
|
||||
if (scanProgress.value && !exhausted.value) parts.push(`已回溯至 ${fmtTime(scanProgress.value)}`)
|
||||
if (exhausted.value) parts.push('已到 365 天保留期尽头')
|
||||
const text = parts.join(' · ')
|
||||
if (loadingMore.value) return `${text} · 正在加载更早…`
|
||||
if (!hasMore.value) return text
|
||||
return h('span', { class: 'inline-flex items-center gap-1.5' }, [
|
||||
`${text} ·`,
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, type: 'primary', onClick: () => { autoFills = 0; void fetchBatch() } },
|
||||
{ default: () => '加载更早' },
|
||||
),
|
||||
])
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
// 懒加载触发点:翻到已加载数据的最后一页即向后端续取一批
|
||||
if (p >= pageCount.value) void fetchBatch()
|
||||
// 懒加载触发点:翻到已加载数据的最后一页即向后端续取一批(用户动作,重置自动补批额度)
|
||||
if (p >= pageCount.value) {
|
||||
autoFills = 0
|
||||
void fetchBatch()
|
||||
}
|
||||
},
|
||||
onUpdatePageSize: (ps: number) => {
|
||||
pagination.pageSize = ps
|
||||
@@ -47,35 +107,45 @@ const pagination = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
/** 重置信息流:回到最新一批 */
|
||||
/** 重置信息流:回到最新一批(刷新按钮与关键字变化共用) */
|
||||
async function load() {
|
||||
clearTimeout(qTimer)
|
||||
const g = ++gen
|
||||
loadedQ = q.value.trim()
|
||||
autoFills = 0
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
rows.value = []
|
||||
cursor.value = ''
|
||||
exhausted.value = false
|
||||
scannedThrough.value = ''
|
||||
pagination.page = 1
|
||||
try {
|
||||
await fetchBatch(true)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (g === gen) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 向更早方向续取一批(~100 条):追加去重后按时间重排;
|
||||
* 末页不满且还有更早数据时自动补一批,避免首屏残页 */
|
||||
/** 向更早方向续取一批(~200 条):追加去重后按时间重排;
|
||||
* 末页不满且还有更早数据时自动补一批,避免首屏残页;
|
||||
* 代次不符的在途响应直接丢弃,防止旧关键字结果混入新信息流 */
|
||||
async function fetchBatch(first = false) {
|
||||
if (loadingMore.value || (!first && !hasMore.value)) return
|
||||
if (!first && (loadingMore.value || !hasMore.value)) return
|
||||
const g = gen
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const r = await listAuditEvents(props.cfgId, { cursor: cursor.value || undefined })
|
||||
const r = await listAuditEvents(props.cfgId, { cursor: cursor.value || undefined, q: q.value.trim() || undefined })
|
||||
if (g !== gen) return
|
||||
const seen = new Set(rows.value.map((e) => e.eventId))
|
||||
rows.value = [...rows.value, ...r.items.filter((e) => !seen.has(e.eventId))].sort((a, b) =>
|
||||
(b.eventTime ?? '').localeCompare(a.eventTime ?? ''),
|
||||
)
|
||||
cursor.value = r.cursor ?? ''
|
||||
exhausted.value = r.exhausted
|
||||
scannedThrough.value = r.scannedThrough ?? ''
|
||||
} catch (e) {
|
||||
if (g !== gen) return
|
||||
if (first) {
|
||||
error.value = e instanceof Error ? e.message : '查询失败'
|
||||
} else {
|
||||
@@ -83,9 +153,13 @@ async function fetchBatch(first = false) {
|
||||
message.error(e instanceof Error ? e.message : '继续加载失败,请刷新重新查询')
|
||||
}
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
if (g === gen) loadingMore.value = false
|
||||
}
|
||||
// 首屏残页自动补批:封顶 maxAutoFills 次,命中稀疏时停驻等用户点「加载更早」
|
||||
if (g === gen && hasMore.value && rows.value.length < pagination.pageSize && autoFills < maxAutoFills) {
|
||||
autoFills++
|
||||
void fetchBatch()
|
||||
}
|
||||
if (hasMore.value && rows.value.length < pagination.pageSize) void fetchBatch()
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
@@ -108,7 +182,11 @@ function rowProps(row: AuditEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// 原始 JSON 加载代次:连续点开不同行时,先点行的迟到响应不得盖住后点行的详情
|
||||
let rawSeq = 0
|
||||
|
||||
async function loadRaw(row: AuditEvent) {
|
||||
const seq = ++rawSeq
|
||||
detailRaw.value = null
|
||||
rawGone.value = false
|
||||
if (!row.eventId || !row.eventTime) {
|
||||
@@ -121,11 +199,11 @@ async function loadRaw(row: AuditEvent) {
|
||||
eventId: row.eventId,
|
||||
eventTime: row.eventTime,
|
||||
})
|
||||
detailRaw.value = raw
|
||||
if (seq === rawSeq) detailRaw.value = raw
|
||||
} catch {
|
||||
rawGone.value = true
|
||||
if (seq === rawSeq) rawGone.value = true
|
||||
} finally {
|
||||
rawLoading.value = false
|
||||
if (seq === rawSeq) rawLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,19 +280,24 @@ const columns: DataTableColumns<AuditEvent> = [
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
|
||||
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">审计日志</div>
|
||||
<StatusBadge v-if="exhausted" kind="run" label="已加载全部保留期数据" :dot="false" />
|
||||
<div class="flex-1" />
|
||||
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
|
||||
<NInputGroup class="!w-76 max-lg:!w-full">
|
||||
<NInputGroupLabel size="small">搜索</NInputGroupLabel>
|
||||
<NInput
|
||||
v-model:value="q"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="事件 / 资源 / 操作者,支持 *"
|
||||
@keydown.enter="submitSearch()"
|
||||
@clear="submitSearch()"
|
||||
/>
|
||||
</NInputGroup>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="error"
|
||||
class="px-5 py-10 text-center text-[13px] text-ink-3"
|
||||
>
|
||||
{{ error }}
|
||||
</div>
|
||||
<EmptyCard v-if="error" title="审计日志加载失败" :note="error" />
|
||||
<NDataTable
|
||||
v-else
|
||||
:columns="columns"
|
||||
@@ -225,13 +308,11 @@ const columns: DataTableColumns<AuditEvent> = [
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
OCI Audit 免费且默认开启,事件保留 365 天,无需在云端做任何配置 ·
|
||||
实时查询不入库,事件通常延迟数分钟可见 ·
|
||||
已默认过滤遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调 ·
|
||||
从最新事件起每批约 100 条,翻到末页自动向更早加载,空档期自动扩窗回溯 · 点击行查看完整详情
|
||||
事件通常延迟数分钟可见 ·
|
||||
从最新事件起每批约 200 条,翻到末页自动向更早加载,命中较少时自动补两批后停驻
|
||||
</FootNote>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
@@ -254,7 +335,7 @@ const columns: DataTableColumns<AuditEvent> = [
|
||||
v-if="detailRaw"
|
||||
:value="detailRaw"
|
||||
title="原始 JSON"
|
||||
meta="来自 Audit SDK"
|
||||
meta="来自 OCI 审计日志"
|
||||
max-height="36vh"
|
||||
wide
|
||||
/>
|
||||
|
||||
@@ -2,49 +2,122 @@
|
||||
import { NSelect, NSpin } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { getCosts } from '@/api/analytics'
|
||||
import { getCosts, type CostQuery } from '@/api/analytics'
|
||||
import CostChart from '@/components/CostChart.vue'
|
||||
import EmptyCard from '@/components/EmptyCard.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
|
||||
const props = defineProps<{ cfgId: number }>()
|
||||
|
||||
const rangeDays = ref(14)
|
||||
/** 'today' 走小时粒度(本地零点起);数字为最近 N 天(天粒度) */
|
||||
const range = ref<'today' | 14 | 30>(14)
|
||||
const rangeOptions = [
|
||||
{ label: '今天', value: 'today' as const },
|
||||
{ label: '近 14 天', value: 14 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
]
|
||||
|
||||
const costs = useAsync(() => getCosts(props.cfgId, { granularity: 'DAILY', groupBy: 'service' }))
|
||||
watch(rangeDays, () => void costs.run())
|
||||
|
||||
const daily = computed(() => {
|
||||
const byDay = new Map<string, number>()
|
||||
for (const item of costs.data.value ?? []) {
|
||||
const day = item.timeStart.slice(5, 10)
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + item.computedAmount)
|
||||
/** 一次复合分组查询(service,skuName):总额/曲线/服务/产品全部本地聚合,不加请求 */
|
||||
function buildQuery(): CostQuery {
|
||||
const q: CostQuery = { groupBy: 'service,skuName' }
|
||||
if (range.value === 'today') {
|
||||
const midnight = new Date()
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
q.granularity = 'HOURLY'
|
||||
q.startTime = midnight.toISOString()
|
||||
} else {
|
||||
q.granularity = 'DAILY'
|
||||
q.startTime = new Date(Date.now() - range.value * 86400e3).toISOString()
|
||||
}
|
||||
const labels = [...byDay.keys()].sort()
|
||||
return { labels, values: labels.map((l) => +(byDay.get(l) ?? 0).toFixed(2)) }
|
||||
return q
|
||||
}
|
||||
|
||||
const costs = useAsync(() => getCosts(props.cfgId, buildQuery()))
|
||||
watch(range, () => void costs.run())
|
||||
|
||||
/** 时间桶标签:今天按本地小时(HH:00),天粒度按 MM-DD(UTC 日) */
|
||||
function bucketLabel(timeStart: string): string {
|
||||
if (range.value !== 'today') return timeStart.slice(5, 10)
|
||||
return `${String(new Date(timeStart).getHours()).padStart(2, '0')}:00`
|
||||
}
|
||||
|
||||
/** 完整时间轴:Usage API 对无出账记录的时段不返回行,不补零桶会断轴缺刻度 */
|
||||
function fullLabels(): string[] {
|
||||
if (range.value === 'today') {
|
||||
const hours = new Date().getHours()
|
||||
return Array.from({ length: hours + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`)
|
||||
}
|
||||
const days = range.value
|
||||
return Array.from({ length: days + 1 }, (_, i) =>
|
||||
new Date(Date.now() - (days - i) * 86400e3).toISOString().slice(5, 10),
|
||||
)
|
||||
}
|
||||
|
||||
const series = computed(() => {
|
||||
const byBucket = new Map<string, number>()
|
||||
for (const item of costs.data.value ?? []) {
|
||||
const key = bucketLabel(item.timeStart)
|
||||
byBucket.set(key, (byBucket.get(key) ?? 0) + item.computedAmount)
|
||||
}
|
||||
const labels = fullLabels()
|
||||
return { labels, values: labels.map((l) => +(byBucket.get(l) ?? 0).toFixed(2)) }
|
||||
})
|
||||
|
||||
const total = computed(() => daily.value.values.reduce((s, v) => s + v, 0))
|
||||
const total = computed(() => series.value.values.reduce((s, v) => s + v, 0))
|
||||
|
||||
const byService = computed(() => {
|
||||
const map = new Map<string, number>()
|
||||
interface ProductRow {
|
||||
name: string
|
||||
amount: number
|
||||
pct: number
|
||||
}
|
||||
|
||||
interface ServiceRow {
|
||||
name: string
|
||||
amount: number
|
||||
pct: number
|
||||
barWidth: string
|
||||
products: ProductRow[]
|
||||
}
|
||||
|
||||
/** 服务榜单 + 各服务内产品(SKU)明细,均按金额倒序 */
|
||||
const byService = computed<ServiceRow[]>(() => {
|
||||
const svcTotal = new Map<string, number>()
|
||||
const skuTotal = new Map<string, Map<string, number>>()
|
||||
for (const item of costs.data.value ?? []) {
|
||||
map.set(item.groupValue, (map.get(item.groupValue) ?? 0) + item.computedAmount)
|
||||
const svc = item.groupValue || '(未知服务)'
|
||||
svcTotal.set(svc, (svcTotal.get(svc) ?? 0) + item.computedAmount)
|
||||
const skus = skuTotal.get(svc) ?? new Map<string, number>()
|
||||
const sku = item.subValue || '(未细分)'
|
||||
skus.set(sku, (skus.get(sku) ?? 0) + item.computedAmount)
|
||||
skuTotal.set(svc, skus)
|
||||
}
|
||||
const rows = [...map.entries()].sort((a, b) => b[1] - a[1])
|
||||
const rows = [...svcTotal.entries()].sort((a, b) => b[1] - a[1])
|
||||
const max = rows[0]?.[1] ?? 1
|
||||
return rows.map(([name, amount]) => ({
|
||||
name,
|
||||
amount,
|
||||
pct: total.value ? Math.round((amount / total.value) * 100) : 0,
|
||||
barWidth: `${Math.round((amount / max) * 100)}%`,
|
||||
products: [...(skuTotal.get(name) ?? new Map<string, number>()).entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([sku, amt]) => ({
|
||||
name: sku,
|
||||
amount: amt,
|
||||
pct: amount ? Math.round((amt / amount) * 100) : 0,
|
||||
})),
|
||||
}))
|
||||
})
|
||||
|
||||
// ---- 展开态:按服务名记录,切范围后清空 ----
|
||||
const expanded = ref(new Set<string>())
|
||||
watch(range, () => (expanded.value = new Set()))
|
||||
|
||||
function toggle(name: string) {
|
||||
const next = new Set(expanded.value)
|
||||
if (next.has(name)) next.delete(name)
|
||||
else next.add(name)
|
||||
expanded.value = next
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,7 +126,7 @@ const byService = computed(() => {
|
||||
<div class="text-sm font-semibold">成本分析</div>
|
||||
<div class="flex-1" />
|
||||
<div class="w-28">
|
||||
<NSelect v-model:value="rangeDays" size="small" :options="rangeOptions" />
|
||||
<NSelect v-model:value="range" size="small" :options="rangeOptions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,23 +137,33 @@ const byService = computed(() => {
|
||||
<div class="flex flex-wrap items-baseline gap-2.5 px-4.5 pt-3.5">
|
||||
<div class="text-[26px] font-semibold tabular-nums">${{ total.toFixed(2) }}</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
{{ daily.labels[0] }} 至 {{ daily.labels.at(-1) }} 合计
|
||||
{{
|
||||
range === 'today'
|
||||
? '今天 0 点至今合计(小时粒度)'
|
||||
: `${series.labels[0]} 至 ${series.labels.at(-1)} 合计`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-1">
|
||||
<CostChart :labels="daily.labels" :values="daily.values" />
|
||||
<CostChart :labels="series.labels" :values="series.values" />
|
||||
</div>
|
||||
|
||||
<div class="border-t border-line-soft px-4.5 py-3">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div class="text-[13px] font-semibold">按服务拆分</div>
|
||||
<div class="text-[12.5px] text-ink-3">本期合计 ${{ total.toFixed(2) }}</div>
|
||||
<div class="text-[12.5px] text-ink-3">点击服务行展开产品 · 本期合计 ${{ total.toFixed(2) }}</div>
|
||||
</div>
|
||||
<template v-for="svc in byService" :key="svc.name">
|
||||
<div
|
||||
v-for="svc in byService"
|
||||
:key="svc.name"
|
||||
class="flex items-center gap-3 py-1.5 max-md:gap-2"
|
||||
class="flex cursor-pointer items-center gap-3 rounded py-1.5 select-none hover:bg-wash/70 max-md:gap-2"
|
||||
@click="toggle(svc.name)"
|
||||
>
|
||||
<span
|
||||
class="w-3 flex-none text-center text-[10px] text-ink-3 transition-transform"
|
||||
:class="expanded.has(svc.name) ? 'rotate-90' : ''"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
<div class="w-32 flex-none truncate text-[13px] max-md:w-24" :title="svc.name">
|
||||
{{ svc.name }}
|
||||
</div>
|
||||
@@ -90,17 +173,34 @@ const byService = computed(() => {
|
||||
<div class="w-17 flex-none text-right text-[13px] tabular-nums">
|
||||
${{ svc.amount.toFixed(2) }}
|
||||
</div>
|
||||
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">{{ svc.pct }}%</div>
|
||||
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">
|
||||
{{ svc.pct }}%
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded.has(svc.name)" class="mb-1 ml-3 border-l border-line-soft pl-4">
|
||||
<div
|
||||
v-for="p in svc.products"
|
||||
:key="p.name"
|
||||
class="flex items-center gap-3 py-1 max-md:gap-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1 truncate text-[12.5px] text-ink-2" :title="p.name">
|
||||
{{ p.name }}
|
||||
</div>
|
||||
<div class="w-17 flex-none text-right text-[12.5px] tabular-nums">
|
||||
${{ p.amount.toFixed(2) }}
|
||||
</div>
|
||||
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">
|
||||
{{ p.pct }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<EmptyCard
|
||||
v-else
|
||||
title="暂无成本数据"
|
||||
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天"
|
||||
:title="range === 'today' ? '今天暂无成本数据' : '暂无成本数据'"
|
||||
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天,「今天」视图可能滞后"
|
||||
/>
|
||||
<FootNote>
|
||||
每日 03:30 自动同步 Usage API · 本地快照 · 免费类别租户不参与成本同步 · 点击刷新可即时拉取
|
||||
</FootNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||||
import { NButton, NCheckbox, NInput, NSelect, NSwitch } from 'naive-ui'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { createIdp, getIdentitySetting, type CreateIdpBody } from '@/api/tenant'
|
||||
import {
|
||||
createIdp,
|
||||
deleteIdpIcon,
|
||||
getIdentitySetting,
|
||||
uploadIdpIcon,
|
||||
type CreateIdpBody,
|
||||
} from '@/api/tenant'
|
||||
import FilePicker from '@/components/FilePicker.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { IdpIconUpload } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ show: boolean; cfgId: number; domainId?: string }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
|
||||
@@ -15,6 +22,7 @@ const message = useToast()
|
||||
const submitting = ref(false)
|
||||
// 「用户需要提供主电子邮件地址」开启时 JIT 必须映射邮箱
|
||||
const primaryEmailRequired = ref(false)
|
||||
const identitySettingReady = ref(false)
|
||||
|
||||
const nameIdFormatOptions = [
|
||||
{ label: '无', value: 'saml-none' },
|
||||
@@ -51,22 +59,66 @@ const blank = {
|
||||
}
|
||||
const form = reactive({ ...blank })
|
||||
|
||||
// ---- 图标上传:传到身份域公共图片存储(/storage/v1/Images),回填公网 URL ----
|
||||
const iconFileEl = ref<HTMLInputElement | null>(null)
|
||||
const iconUploading = ref(false)
|
||||
// 最近上传且尚未被创建采用的图标;替换、放弃表单或提交未引用时尽力删除。
|
||||
// 删除失败只会在自己租户里留下一张无入口的小图,静默接受,不打扰用户。
|
||||
let pendingIcon: (IdpIconUpload & { cfgId: number; domainId?: string }) | null = null
|
||||
let identitySettingSeq = 0
|
||||
// 图片加载失败时回退占位,避免预览框里出现浏览器裂图
|
||||
const iconError = ref(false)
|
||||
watch(
|
||||
() => props.show,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
() => form.iconUrl,
|
||||
() => (iconError.value = false),
|
||||
)
|
||||
// 上传请求序号:重开弹窗或重新选文件即自增,旧请求的响应按序号丢弃
|
||||
let iconUploadSeq = 0
|
||||
|
||||
function invalidateIconUpload() {
|
||||
iconUploadSeq++
|
||||
iconUploading.value = false
|
||||
}
|
||||
|
||||
function cleanupPendingIcon() {
|
||||
const icon = pendingIcon
|
||||
pendingIcon = null
|
||||
if (icon) void deleteIdpIcon(icon.cfgId, icon.fileName, icon.domainId).catch(() => {})
|
||||
}
|
||||
|
||||
function abandonForm() {
|
||||
identitySettingSeq++
|
||||
identitySettingReady.value = false
|
||||
invalidateIconUpload()
|
||||
cleanupPendingIcon()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
abandonForm()
|
||||
primaryEmailRequired.value = false
|
||||
Object.assign(form, blank)
|
||||
void loadIdentitySetting()
|
||||
},
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.cfgId, props.domainId] as const,
|
||||
([show]) => (show ? resetForm() : abandonForm()),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// show/cfgId/domainId 任一变化都会经 abandonForm 递增序号,故过期只需比对序号
|
||||
async function loadIdentitySetting() {
|
||||
const seq = ++identitySettingSeq
|
||||
identitySettingReady.value = false
|
||||
try {
|
||||
const s = await getIdentitySetting(props.cfgId)
|
||||
const s = await getIdentitySetting(props.cfgId, props.domainId)
|
||||
if (seq !== identitySettingSeq) return
|
||||
primaryEmailRequired.value = s.primaryEmailRequired
|
||||
if (s.primaryEmailRequired) form.jitMapEmail = true
|
||||
} catch {
|
||||
primaryEmailRequired.value = false
|
||||
if (seq === identitySettingSeq) primaryEmailRequired.value = false
|
||||
} finally {
|
||||
if (seq === identitySettingSeq) identitySettingReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +128,60 @@ const iconUrlValid = computed(() => {
|
||||
return !v || /^https?:\/\//.test(v)
|
||||
})
|
||||
|
||||
async function onIconPick(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file || submitting.value) return
|
||||
if (file.size > 1 << 20) {
|
||||
message.error('图标不能超过 1MB')
|
||||
return
|
||||
}
|
||||
const { cfgId, domainId } = props
|
||||
const seq = ++iconUploadSeq
|
||||
iconUploading.value = true
|
||||
try {
|
||||
const result = await uploadIdpIcon(cfgId, file, domainId)
|
||||
if (seq !== iconUploadSeq) {
|
||||
void deleteIdpIcon(cfgId, result.fileName, domainId).catch(() => {})
|
||||
return
|
||||
}
|
||||
cleanupPendingIcon()
|
||||
pendingIcon = { ...result, cfgId, domainId }
|
||||
form.iconUrl = result.url
|
||||
message.success('图标已上传到身份域公共存储')
|
||||
} catch (err) {
|
||||
if (seq === iconUploadSeq) message.error(err instanceof Error ? err.message : '上传失败')
|
||||
} finally {
|
||||
if (seq === iconUploadSeq) iconUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onIconUrlInput(value: string) {
|
||||
if (submitting.value || value === form.iconUrl) return
|
||||
invalidateIconUpload()
|
||||
form.iconUrl = value
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
abandonForm()
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function onModalShowChange(show: boolean) {
|
||||
if (!show && submitting.value) return
|
||||
if (show) emit('update:show', true)
|
||||
else closeForm()
|
||||
}
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
!!form.name.trim() &&
|
||||
form.metadata.includes('EntityDescriptor') &&
|
||||
iconUrlValid.value &&
|
||||
!iconUploading.value &&
|
||||
identitySettingReady.value &&
|
||||
!submitting.value &&
|
||||
(form.idpUserAttr !== 'attribute' || !!form.assertionAttr.trim()),
|
||||
)
|
||||
|
||||
@@ -102,11 +203,18 @@ function buildBody(): CreateIdpBody {
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value || !canSubmit.value) return
|
||||
const body = buildBody()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createIdp(props.cfgId, buildBody(), props.domainId)
|
||||
message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||||
emit('update:show', false)
|
||||
const created = await createIdp(props.cfgId, body, props.domainId)
|
||||
// 创建请求引用了刚上传的图标即视为已采用,交给 IdP 生命周期,不再清理
|
||||
if (pendingIcon && pendingIcon.url === body.iconUrl) pendingIcon = null
|
||||
const warning = created.setupWarning
|
||||
if (warning)
|
||||
message.warning(`IdP「${created.name}」的创建结果需确认`, `${warning.message}\n关联 ID:${warning.requestId}`)
|
||||
else message.success(`IdP「${created.name}」已创建(禁用态),激活后显示到登录页`)
|
||||
closeForm()
|
||||
emit('created')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '创建失败')
|
||||
@@ -124,12 +232,17 @@ async function submit() {
|
||||
:submitting="submitting"
|
||||
:submit-disabled="!canSubmit"
|
||||
submit-text="创建(禁用态)"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@update:show="onModalShowChange"
|
||||
@submit="submit"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="名称" required>
|
||||
<NInput v-model:value="form.name" placeholder="my-idp" />
|
||||
</FormField>
|
||||
<FormField label="备注">
|
||||
<NInput v-model:value="form.description" placeholder="可选" />
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField
|
||||
label="IdP SAML metadata XML 文件"
|
||||
required
|
||||
@@ -153,26 +266,49 @@ async function submit() {
|
||||
@load="(text) => (form.metadata = text)"
|
||||
/>
|
||||
</FormField>
|
||||
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
|
||||
<FormField label="备注">
|
||||
<NInput v-model:value="form.description" placeholder="可选" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Logo(登录页图标)"
|
||||
hint="展示在登录页 IdP 按钮上;身份域只接受 http(s) 外链图片 URL,不支持上传"
|
||||
hint="展示在登录页 IdP 按钮上;可上传到身份域公共存储(≤1MB),或直接填 http(s) 外链 URL"
|
||||
:error="iconUrlValid ? undefined : '需为 http(s):// 开头的图片外链 URL'"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="flex h-[34px] w-[34px] flex-none items-center justify-center overflow-hidden rounded border border-line-soft bg-wash"
|
||||
>
|
||||
<img
|
||||
v-if="form.iconUrl && iconUrlValid"
|
||||
v-if="form.iconUrl && iconUrlValid && !iconError"
|
||||
:src="form.iconUrl"
|
||||
alt=""
|
||||
class="h-7 w-7 flex-none rounded border border-line-soft object-contain"
|
||||
class="h-full w-full object-contain"
|
||||
@error="iconError = true"
|
||||
/>
|
||||
<span v-else class="text-[10px] text-ink-3 select-none">Logo</span>
|
||||
</div>
|
||||
<NInput
|
||||
:value="form.iconUrl"
|
||||
:disabled="submitting"
|
||||
placeholder="https://…/logo.png,或点右侧上传"
|
||||
class="min-w-0 flex-1"
|
||||
@update:value="onIconUrlInput"
|
||||
/>
|
||||
<NButton
|
||||
class="flex-none"
|
||||
:loading="iconUploading"
|
||||
:disabled="submitting"
|
||||
@click="iconFileEl?.click()"
|
||||
>
|
||||
上传
|
||||
</NButton>
|
||||
<input
|
||||
ref="iconFileEl"
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.svg,.webp,.ico"
|
||||
:disabled="submitting"
|
||||
class="hidden"
|
||||
@change="onIconPick"
|
||||
/>
|
||||
<NInput v-model:value="form.iconUrl" placeholder="https://…/logo.png" />
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 mb-2 border-t border-line-soft pt-3 text-[12.5px] font-semibold">
|
||||
映射用户身份
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user