22 Commits
Author SHA1 Message Date
wangdefa efcb84eaa8 发布 v0.8.0
CI / test (push) Successful in 42s
Release / release (push) Successful in 43s
2026-07-21 19:40:45 +08:00
wangdefa 6129173fe6 三页面重设计与移动端适配;接入 PWA 与构建分包
CI / test (push) Successful in 47s
2026-07-21 19:26:22 +08:00
wangdefa ac457282ce 新增对象存储/账单/保留IP页面,成本今天视图与样式收敛
CI / test (push) Successful in 42s
2026-07-21 12:11:22 +08:00
wangdefa d813225ac4 发布 v0.7.3
CI / test (push) Successful in 26s
Release / release (push) Successful in 27s
2026-07-17 16:58:12 +08:00
wangdefa 3475ce7ce6 回传日志去高度限制与文案同步,错误 toast 可展开详情
CI / test (push) Successful in 27s
2026-07-17 16:53:03 +08:00
wangdefa c0229b60c1 实例表单:默认 2C12G、去免费文案、配额按 AD 联动 2026-07-17 16:53:03 +08:00
wangdefa fbed8d1ea4 删除未引用的 PageHeader 死组件
CI / test (push) Successful in 26s
2026-07-17 12:19:28 +08:00
wangdefa 1fc5d74d8a 发布 v0.7.2
CI / test (push) Successful in 26s
Release / release (push) Successful in 26s
2026-07-16 21:25:40 +08:00
wangdefa 4606fbfd4f 设置页上游预算项、代理状态列、调用日志 Tokens 列宽
CI / test (push) Successful in 26s
2026-07-16 21:20:14 +08:00
wangdefa 0ff0e22e56 发布 v0.7.1
CI / test (push) Successful in 25s
Release / release (push) Successful in 26s
2026-07-16 16:41:03 +08:00
wangdefa 6ea222e5ab 审计日志搜索框:防抖检索、回溯进度与补批封顶
CI / test (push) Successful in 27s
2026-07-16 16:36:42 +08:00
wangdefa b8c0ddda6d 发布 v0.7.0
CI / test (push) Successful in 26s
Release / release (push) Successful in 26s
2026-07-16 12:58:27 +08:00
wangdefa d987400a5b 设置页新增AI Tab:保险丝/grok工具/模型治理迁入
CI / test (push) Successful in 28s
2026-07-16 12:32:21 +08:00
wangdefa 6a7757ae2f 实例VNIC:附加后自动刷新、IPv6限挂载态与脚注修正 2026-07-16 12:32:20 +08:00
wangdefa 78cc2aab56 渠道模型列表弹窗与模型数量列
CI / test (push) Successful in 28s
Release / release (push) Successful in 26s
2026-07-14 19:33:55 +08:00
wangdefa 08fa529348 登录页加载态、日志动作补齐与绑定按钮禁用态
CI / test (push) Successful in 27s
Release / release (push) Successful in 28s
2026-07-14 11:01:09 +08:00
wangdefa e2cdb99194 接入信息补tts端点,可用模型新增过滤弃用开关
CI / test (push) Successful in 25s
Release / release (push) Successful in 25s
- 接入信息展示 POST /tts(xAI 官方格式)
- 可用模型卡片「过滤弃用」NSwitch:GET/PUT /api/v1/ai-settings,切换即时刷新;修复 request body 双重 stringify
- CHANGELOG 0.5.0(0.4.0 已发版冻结)
2026-07-14 09:50:01 +08:00
wangdefa 7f529bd991 AI网关接入信息补齐新端点,CHANGELOG 0.4.0
CI / test (push) Successful in 26s
Release / release (push) Successful in 26s
2026-07-13 20:14:28 +08:00
wangdefa aea00c2382 关于页运行状态、代理关联租户与步进框修复
CI / test (push) Successful in 25s
Release / release (push) Successful in 25s
2026-07-13 12:21:28 +08:00
wangdefa 2ea5b73de2 移除告警规则界面、恢复 Chat 端点并优化列表
CI / test (push) Successful in 25s
Release / release (push) Successful in 26s
2026-07-13 10:08:08 +08:00
Wang Defa 94fbe62e4c 模型黑名单与密钥模型白名单界面、分组填入、删除弹窗排版
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
2026-07-12 17:44:46 +08:00
Wang Defa 5464d2dfea 发布 0.1.0:通知渠道、告警规则与多项体验修复
CI / test (push) Successful in 27s
Release / release (push) Successful in 26s
2026-07-10 17:31:19 +08:00
157 changed files with 15574 additions and 1483 deletions
+4 -1
View File
@@ -19,6 +19,7 @@ jobs:
npm ci
npm run build
cd dist && zip -qr ../dist.zip . && cd ..
sha256sum dist.zip > dist.zip.sha256
- name: 提取 CHANGELOG 版本段作为 Release 描述
run: |
@@ -31,4 +32,6 @@ jobs:
with:
token: ${{ secrets.BUILD_TOKEN }}
body_path: release-notes.md
files: dist.zip
files: |
dist.zip
dist.zip.sha256
+4 -1
View File
@@ -22,6 +22,7 @@ jobs:
npm ci
npm run build
cd dist && zip -qr ../dist.zip . && cd ..
sha256sum dist.zip > dist.zip.sha256
ls -lh dist.zip
- name: 提取 CHANGELOG 版本段作为 Release 描述
@@ -37,4 +38,6 @@ jobs:
with:
token: ${{ github.token }}
body_path: release-notes.md
files: dist.zip
files: |
dist.zip
dist.zip.sha256
+1 -1
View File
@@ -1 +1 @@
0.6.5
0.6.6
+6
View File
@@ -1,5 +1,11 @@
# 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 联动父级刷新)。
@@ -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/`)的目录职责与命名约定。
## 目录职责
@@ -15,7 +15,7 @@ oci-portal-dash/
├── App.vue NConfigProvider 注入 Naive 主题
├── assets/ main.css:Tailwind @theme 设计 token(浅色)+ html.dark 暗色变量覆盖与全局基础样式
├── theme/ tokens.ts(JS 侧 token 唯一来源,含 darkTokens 暗色板)、naive.ts(亮暗两套 themeOverrides 同一工厂生成)
├── types/ api.ts:与 docs/API接口文档.md 一一对应的 DTO 类型
├── types/ api.ts:与后端 swagger(oci-portal/docs/)一一对应的 DTO 类型
├── api/ request 封装(JWT 注入、401 跳登录、错误 hint 拼接)+ 按资源分模块;VITE_MOCK=1 时返回 mock.ts 数据
├── stores/ Pinia:auth(令牌持久化)、app(侧栏收缩、暗色主题:默认跟随系统 + localStorage 记忆,驱动 html.dark 与 Naive darkTheme)、scope(全局作用域:租户/区域/区间,localStorage 按租户记忆)
├── router/ 路由与登录守卫
-51
View File
@@ -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)
+1 -1
View File
@@ -27,7 +27,7 @@ Vue 3 + Vite + TypeScript + Naive UI + Tailwind CSS v4 + ECharts + Pinia。Naive
- [ ] 读过 [Directory Structure](./directory-structure.md),文件放对层(views / components / composables / api / stores / types)
- [ ] 涉及颜色、圆角、间距时先看 [Styling & Design Tokens](./styling-design-tokens.md),禁止硬编码 hex
- [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与 `docs/API接口文档.md` 一一对应)
- [ ] 新增请求走 `api/` 统一封装,DTO 类型进 `types/`(与后端 swagger `oci-portal/docs/` 一一对应)
- [ ] 出 UI 设计稿前读 `../guides/design-workflow.md`
## Quality Check
@@ -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,13 @@
大圆角(>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 引。
## Common Mistake: NModal 宽度不能用 Tailwind class
**Symptom**:`<NModal preset="card" class="w-[480px]">` 弹窗实际渲染为全宽。
@@ -106,6 +113,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 浅底」因用户不接受色块框已废弃。)
-51
View File
@@ -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)
+6 -1
View File
@@ -4,7 +4,7 @@
- 开启 `strict`;禁止 `any`,未知类型用 `unknown` 后收窄。
- 对象形状优先 `interface`,联合/工具类型用 `type`;类型导入写 `import type`
- 与后端交互的 DTO 类型集中在 `types/`,字段与 `docs/API接口文档.md` 一一对应,不在组件里手写内联响应类型。
- 与后端交互的 DTO 类型集中在 `types/`,字段与后端 swagger(`oci-portal/docs/`)一一对应,不在组件里手写内联响应类型。
-`const` 对象 + `as const` 代替 `enum`
## Vue 组件
@@ -14,3 +14,8 @@
- `v-for` 必须带 `key`,且不与 `v-if` 写在同一元素上。
- 组件内样式一律 `scoped`;跨组件复用样式上提到全局 token 或工具类。
- 复用逻辑抽 composable(`useXxx` 命名,放 `composables/`);单文件组件函数遵循 ≤30 行的项目约定。
- 二次确认一律用 `components/ConfirmPop.vue`(批次8 起替代裸 NPopconfirm):kind 分 danger/warn/plain 三态,`onConfirm` 返回 Promise 时确认按钮自动 loading 且弹层锁定;双按钮抉择场景(如终止实例选是否保留引导卷)仍用 `dialog.warning`
- 文件类型图标用 `components/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 -1
View File
@@ -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
View File
@@ -23,6 +23,7 @@ These guides help you **ask the right questions before coding**.
|-------|---------|-------------|
| [Code Reuse Thinking Guide](./code-reuse-thinking-guide.md) | Identify patterns and reduce duplication | When you notice repeated patterns |
| [Cross-Layer Thinking Guide](./cross-layer-thinking-guide.md) | Think through data flow across layers | Features spanning multiple layers |
| [Design Workflow](./design-workflow.md) | Claude Design 设计稿产出与评审流程 | 出 UI 设计稿 / 评审视觉方案时 |
---
+13 -13
View File
@@ -183,7 +183,7 @@ Complex task: ask the user if you can create a Trellis task and enter the planni
- 1.0 Create task `[required · once]` (only after task-creation consent)
- 1.1 Requirement exploration `[required · repeatable]` (`prd.md`; complex tasks also need `design.md` + `implement.md`)
- 1.2 Research `[optional · repeatable]`
- 1.3 Configure context `[required · once]` — Claude Code, Cursor, OpenCode, Codex, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, 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, Reasonix (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
@@ -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, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
- 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, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[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, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
Spawn the research sub-agent:
@@ -361,7 +361,7 @@ 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, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[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, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
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, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[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, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
| `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, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
---
@@ -472,7 +472,7 @@ Goal: turn reviewed planning artifacts into code that passes quality checks.
#### 2.1 Implement `[required · repeatable]`
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
[Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
Spawn the implement sub-agent:
@@ -484,7 +484,7 @@ 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
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi]
[/Claude Code, Cursor, OpenCode, CodeBuddy, Droid, Pi, Oh My Pi]
[codex-sub-agent, Gemini, Qoder, Copilot, ZCode, Reasonix, Trae]
@@ -526,7 +526,7 @@ 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, ZCode, Reasonix, Trae]
[Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
Spawn the check sub-agent:
@@ -540,7 +540,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, ZCode, Reasonix, Trae]
[/Claude Code, Cursor, OpenCode, codex-sub-agent, Kiro, Gemini, Qoder, CodeBuddy, Copilot, Droid, Pi, Oh My Pi, ZCode, Reasonix, Trae]
[codex-inline, Kilo, Antigravity, Devin]
+185 -2
View File
@@ -1,8 +1,191 @@
# Changelog
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)(版本段不记日期),版本号遵循语义化版本。
## [0.0.1] - 2026-07-09
## [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
- 设置 · 账号安全绑定入口:已绑定的登录方式按钮置禁用态并显示「已绑定」,解绑后自动恢复可点
### Fixed
- 修复禁用密码登录后登录页闪烁:登录方式加载完成前显示加载态,不再先渲染密码表单再切换,同时消除了闪烁窗口内误提交密码触发 403「密码登录已禁用」日志的问题
- 系统日志「动作」列补齐 26 条缺失描述(退出登录、外部身份登录 / 绑定、修改登录凭据、AI 密钥 / 渠道 / 黑名单 / 网关设置、通知渠道 / 模板、代理导入 / 探测 / 关联租户等)
## [0.5.0]
### Added
- AI 网关「接入信息」新增 `POST /tts` 端点展示(文本转语音 · xAI 官方格式)
- AI 网关「可用模型」新增「过滤弃用」开关:开启后已宣布弃用(即使未退役)的模型从模型列表与路由中排除,状态持久化,切换即时刷新列表
## [0.4.0]
### Added
- AI 网关「接入信息」补齐新端点展示:`POST /audio/speech`(文本转语音 · xAI Voice)、`POST /rerank`(文档重排 · Cohere Rerank)、`POST /moderations`(内容审核 · OCI Guardrails
## [0.3.0]
### Added
- AI 网关端点列表恢复展示 OpenAI Chat Completions 接口(`POST /ai/v1/chat/completions`),标明支持流式与非流式调用
- 租户列表分页支持 10 / 20 / 50 / 100 条每页切换
- 设置 · 关于新增「运行状态」面板:运行时长(随查看自增)与 CPU 均值 / 内存(堆)/ 存储占用 / Goroutines 四格指标,明暗主题与窄屏两列自适应;旧版后端缺运行时字段时面板自动隐藏
- 代理列表新增「关联租户」管理:弹窗回显当前关联名单(支持点 ✕ 移除),多选增删保存即设置全集,选中已关联其他代理的租户时提示保存后改挂
### Changed
- 回传日志告警统一改由「通知管理 → 云端事件」分类推送,租户删除提示同步移除已废弃的告警规则清理项
- 总览页租户测活列表改为限高滚动,「查看全部租户」入口固定展示
- 系统日志、OCI 审计日志与配额表格取消固定高度,分页内容随页面完整展开
- 代理列表「认证」列由用户名明文拼接改为「是 / 否」徽标,悬停「是」显示用户名(无用户名提示仅密码认证)
### Removed
- 移除日志页自定义审计告警规则的管理入口、条件表单及相关前端 API
### Fixed
- 修复租户列表分页状态在页面重绘时丢失,以及筛选后停留在越界页码的问题
- 修复「引导卷(GB)」等可清空数字输入框的步进箭头挤出输入框右缘的错位;清空图标改为悬浮在步进竖栏左侧,输入文本自动避让
## [0.2.0]
### Added
- AI 网关页「模型黑名单」管理:模型列表按厂商手风琴分组展示,可一键拉黑(全渠道剔除)、移除后重新同步渠道恢复
- AI 密钥模型白名单编辑:创建 / 编辑密钥时可限定放行的模型列表(空 = 不限)
- 渠道 / 密钥表单「现有分组」chips:点击即填入分组输入框
### Changed
- 租户删除确认弹窗分段排版(标题 / 面板清理范围 / 云端提示),列表与详情页文案一致
## [0.1.0]
### Added
- 设置页「通知渠道」卡片:Webhook(Slack / 飞书 / 钉钉 / 企微预设)、ntfy、Bark、SMTP 与 Telegram 五渠道管理
- 日志页审计告警规则管理:列表、组合匹配表单与通知模板入口
- 账号安全新增「撤销全部会话」入口
- 租户暂停状态三态展示(详情 / 列表 / 总览)
### Changed
- 实例详情 root 密码默认掩码、点击查看;串口控制台移除「复制 root 密码」
- 修改登录凭据后当前会话无感续期;其余敏感操作同样自动换用新令牌
- 域选择器仅显示域名(去除许可类型后缀)
### Fixed
- 明暗主题扩散切换时的闪烁
- 任务表单数字输入框在 small 尺寸下步进按钮溢出
- 租户详情「用户」「策略」等 Tab 切换域后内容不刷新
- 实例详情区块加载中时禁用头部操作按钮,防误点
## [0.0.1]
首个版本:OCI Portal 前端。
+4
View File
@@ -28,6 +28,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
构建产物自带 PWAvite-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/)。
+2
View File
@@ -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>
+5185 -7
View File
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -1,7 +1,7 @@
{
"name": "oci-portal-dash",
"private": true,
"version": "0.1.0",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,10 +12,31 @@
"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",
"@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 +44,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 +54,11 @@
"@vue/tsconfig": "^0.7.0",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.2",
"prettier": "^3.9.5",
"tailwindcss": "^4.0.0",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vite-plugin-pwa": "^1.3.0",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.
+11
View File
@@ -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

+55
View File
@@ -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`)
+57 -3
View File
@@ -1,5 +1,15 @@
import { request } from './request'
import type { AiCallLog, AiChannel, AiContentLog, AiKey, AiModel, AiModelCacheItem } from '@/types/api'
import { mockOn, mocked, request } from './request'
import type {
AiCallLog,
AiCatalogModel,
AiChannel,
AiContentLog,
AiKey,
AiModel,
AiModelBlacklistItem,
AiModelCacheItem,
AiSettings,
} from '@/types/api'
// ---- 网关密钥 ----
@@ -11,6 +21,8 @@ export interface CreateAiKeyRequest {
name: string
value?: string
group?: string
/** 模型白名单;缺省/空 = 不限 */
models?: string[]
}
/** 创建密钥;key 为明文,仅本次响应返回 */
@@ -20,7 +32,7 @@ export function createAiKey(body: CreateAiKeyRequest): Promise<{ key: string; it
export function updateAiKey(
id: number,
body: { name?: string; enabled?: boolean; group?: string },
body: { name?: string; enabled?: boolean; group?: string; models?: string[] },
): Promise<void> {
return request(`/ai-keys/${id}`, { method: 'PUT', body })
}
@@ -37,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)
}
@@ -72,12 +86,52 @@ 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> {
return request<AiSettings>('/ai-settings')
}
export function updateAiSettings(body: AiSettings): Promise<AiSettings> {
return request<AiSettings>('/ai-settings', { method: 'PUT', body })
}
// ---- 模型黑名单 ----
export function listAiBlacklist(): Promise<AiModelBlacklistItem[]> {
return request<{ items: AiModelBlacklistItem[] }>('/ai-blacklist').then((r) => r.items)
}
/** 拉黑模型:删除全部渠道缓存中的同名条目,后续同步 / 探测均过滤 */
export function addAiBlacklist(name: string): Promise<void> {
return request('/ai-blacklist', { method: 'POST', body: { name } })
}
/** 移出黑名单:缓存不回填,重新同步 / 探测后恢复入池 */
export function removeAiBlacklist(id: number): Promise<void> {
return request(`/ai-blacklist/${id}`, { method: 'DELETE' })
}
export function listAiCallLogs(params: {
page: number
size: number
+2 -2
View File
@@ -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[]> {
+25 -14
View File
@@ -5,6 +5,7 @@ import type {
LoginRequest,
LoginResponse,
OAuthSettings,
SessionRefresh,
TotpSetup,
TotpStatus,
UpdateCredentialsRequest,
@@ -26,6 +27,14 @@ export function logout(): Promise<void> {
return request('/auth/logout', { method: 'POST' })
}
// ---- 会话 ----
/** 撤销全部会话:旧 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' })
}
// ---- 登录凭据 ----
export function getCredentials(): Promise<CredentialsInfo> {
@@ -33,15 +42,15 @@ export function getCredentials(): Promise<CredentialsInfo> {
return request('/auth/credentials')
}
/** 修改用户名 / 密码;成功后旧 token 随即失效,调用方应登出重登 */
export function updateCredentials(body: UpdateCredentialsRequest): Promise<void> {
if (mockOn) return mocked(undefined, 400)
/** 修改用户名 / 密码;成功后旧 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 })
}
/** 保存密码登录禁用开关;开启要求至少绑定一个外部身份(后端 409) */
export function updatePasswordLogin(disabled: boolean): Promise<void> {
if (mockOn) return mocked(undefined, 300)
/** 保存密码登录禁用开关;成功后旧 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 } })
}
@@ -62,14 +71,15 @@ export function setupTotp(): Promise<TotpSetup> {
return request('/auth/totp/setup', { method: 'POST' })
}
export function activateTotp(code: string): Promise<void> {
if (mockOn) return mocked(undefined, 300)
/** 激活两步验证;成功后旧 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 } })
}
/** 停用两步验证;密码或当前验证码任一确认 */
export function disableTotp(body: { password?: string; code?: string }): Promise<void> {
if (mockOn) return mocked(undefined, 300)
/** 停用两步验证;密码或当前验证码任一确认,响应带新会话 */
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 })
}
@@ -92,7 +102,7 @@ export function getOauthProviders(): Promise<{
return request('/auth/oauth/providers')
}
/** 获取授权跳转 URL;bind 模式要求已登录(带 JWT) */
/** 获取授权跳转 URL;bind 模式要求已登录 */
export function getOauthAuthorizeUrl(
provider: string,
mode: 'login' | 'bind',
@@ -111,8 +121,9 @@ export function listIdentities(): Promise<{ items: UserIdentityInfo[] }> {
return request('/auth/identities')
}
export function unbindIdentity(id: number): Promise<void> {
if (mockOn) return mocked(undefined, 300)
/** 解绑外部身份;成功后旧 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' })
}
+51
View File
@@ -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)
}
+42 -1
View File
@@ -187,7 +187,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 +241,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 添加 IPv6address 留空自动分配。删除复用实例级 removeIpv6(按地址遍历全部网卡) */
export function addVnicIpv6(
cfgId: number,
+6 -1
View File
@@ -1,6 +1,11 @@
import { mockLogEvents, mockLogRelays, mockLogWebhooks } from './mock'
import { mockOn, mocked, request } from './request'
import type { LogEventPage, LogRelayView, LogWebhookInfo, LogWebhookState } from '@/types/api'
import type {
LogEventPage,
LogRelayView,
LogWebhookInfo,
LogWebhookState,
} from '@/types/api'
export interface LogEventParams {
page: number
+248 -2
View File
@@ -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,
@@ -113,8 +120,9 @@ export const mockConfigs: OciConfig[] = [
promotionStatus: 'EXPIRED',
multiRegion: true,
multiCompartment: true,
proxyId: null,
proxyName: '',
// 与 mockProxies[0].usedBy = 1 呼应:主号经东京 socks5 出站
proxyId: 1,
proxyName: '东京 socks5',
}),
cfg({
id: 2,
@@ -508,6 +516,7 @@ export const mockRegionSubs: RegionSubscription[] = [
]
export const mockLimits: LimitItem[] = [
// A1:三个 AD 均有配额 + 区域总额(镜像真实免费租户形态)
{
name: 'standard-a1-core-count',
scopeType: 'AD',
@@ -516,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',
@@ -524,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',
@@ -541,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 },
]
@@ -994,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: [
{
@@ -1268,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',
},
],
}
@@ -1373,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',
@@ -1381,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',
@@ -1391,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',
@@ -1714,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
View File
@@ -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,
})
}
+361
View File
@@ -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() }
}
+17 -1
View File
@@ -1,4 +1,4 @@
import { mockProxies } from './mock'
import { mockConfigs, mockProxies } from './mock'
import { mockOn, mocked, request } from './request'
import type { ProxyImportResult, ProxyInfo, ProxyInput } from '@/types/api'
@@ -78,6 +78,22 @@ export function importProxies(text: string): Promise<ProxyImportResult> {
return request('/proxies/import', { method: 'POST', body: { text } })
}
/** 代理侧设置关联租户全集:选中即关联(含从其他代理改挂),未选中即解除 */
export function setProxyTenants(id: number, ociConfigIds: number[]): Promise<{ usedBy: number }> {
if (mockOn) {
const found = mockProxies.find((p) => p.id === id)
if (!found) return Promise.reject(new Error('代理不存在'))
for (const c of mockConfigs) {
if (ociConfigIds.includes(c.id)) Object.assign(c, { proxyId: id, proxyName: found.name })
else if (c.proxyId === id) Object.assign(c, { proxyId: null, proxyName: '' })
}
// 改挂会影响其他代理的引用数,全量重算保持一致
for (const p of mockProxies) p.usedBy = mockConfigs.filter((c) => c.proxyId === p.id).length
return mocked({ usedBy: found.usedBy })
}
return request(`/proxies/${id}/tenants`, { method: 'PUT', body: { ociConfigIds } })
}
/** 手动重测出口地区,同步返回最新视图 */
export function probeProxy(id: number): Promise<ProxyInfo> {
if (mockOn) {
+49 -7
View File
@@ -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
}
}
@@ -15,6 +18,8 @@ interface RequestOptions {
method?: string
body?: unknown
query?: Record<string, string | number | boolean | undefined>
/** 附加请求头 */
headers?: Record<string, string>
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
@@ -29,9 +34,23 @@ function buildUrl(path: string, query?: RequestOptions['query']): string {
async function parseError(resp: Response): Promise<never> {
let message = `请求失败(${resp.status}`
let ociCode: string | undefined
try {
const body = (await resp.json()) as { error?: string; hint?: string }
if (body.error) message = body.hint ? `${body.hint}${body.error}` : body.error
const body = (await resp.json()) as {
error?: string
hint?: string
errors?: unknown[]
ociCode?: string
}
ociCode = body.ociCode
if (body.error) {
message = body.hint ? `${body.hint}${body.error}` : body.error
} else {
// 批量接口(如创建实例)全部失败时返回 errors 数组,逐行合并;
// 调用方以「短标题 + detail」经 useToast 展示,多行在详情块中逐行可读
const list = (body.errors ?? []).filter((e): e is string => typeof e === 'string')
if (list.length) message = list.join('\n')
}
} catch {
/* 非 JSON 响应体,保留默认消息 */
}
@@ -40,13 +59,13 @@ async function parseError(resp: Response): Promise<never> {
if (resp.status === 429 && !resp.url.includes('/auth/login') && location.pathname !== '/blocked') {
location.assign('/blocked')
}
throw new ApiError(resp.status, message)
throw new ApiError(resp.status, message, ociCode)
}
/** 统一请求封装:注入 JWT、401 登出、错误转 ApiError */
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const auth = useAuthStore()
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const headers: Record<string, string> = { 'Content-Type': 'application/json', ...opts.headers }
if (auth.token) headers.Authorization = `Bearer ${auth.token}`
const resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
@@ -54,8 +73,31 @@ export async function request<T>(path: string, opts: RequestOptions = {}): Promi
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
// 202/204 等成功响应可能无 body,直接 resp.json() 会抛 Unexpected end of JSON input
const text = await resp.text()
return (text ? JSON.parse(text) : undefined) as T
}
/** 原始请求:注入 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 resp = await fetch(buildUrl(path, opts.query), {
method: opts.method ?? 'GET',
headers,
body: opts.body,
})
if (!resp.ok) await parseError(resp)
return resp
}
export const mockOn = import.meta.env.VITE_MOCK === '1'
+75
View File
@@ -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 },
})
}
+45
View File
@@ -8,12 +8,15 @@ import {
import { mockOn, mocked, request } from './request'
import type {
AboutInfo,
NotifyChannelItem,
NotifyChannelType,
NotifyEventsSetting,
NotifyTemplateItem,
SecuritySetting,
SystemLogPage,
TaskSetting,
TelegramSetting,
UpdateNotifyChannelBody,
} from '@/types/api'
export function getTelegramSetting(): Promise<TelegramSetting> {
@@ -46,6 +49,37 @@ export function testTelegram(): Promise<void> {
return request('/settings/telegram/test', { method: 'POST' })
}
/** 通知渠道(webhook/ntfy/bark/smtp)脱敏视图列表 */
export function listNotifyChannels(): Promise<NotifyChannelItem[]> {
if (mockOn)
return mocked(
(['webhook', 'ntfy', 'bark', 'smtp'] as NotifyChannelType[]).map((type) => ({
type,
enabled: false,
secretSet: false,
})),
)
return request<{ items: NotifyChannelItem[] }>('/settings/notify-channels').then((r) => r.items)
}
/** 保存单渠道配置,返回全渠道最新视图 */
export function updateNotifyChannel(
type: NotifyChannelType,
body: UpdateNotifyChannelBody,
): Promise<NotifyChannelItem[]> {
if (mockOn) return mocked([], 400)
return request<{ items: NotifyChannelItem[] }>(`/settings/notify-channels/${type}`, {
method: 'PUT',
body,
}).then((r) => r.items)
}
/** 向指定渠道发送测试消息(用已保存配置,不看启用开关) */
export function testNotifyChannel(type: NotifyChannelType): Promise<void> {
if (mockOn) return mocked(undefined, 800)
return request(`/settings/notify-channels/${type}/test`, { method: 'POST' })
}
export function getNotifyEvents(): Promise<NotifyEventsSetting> {
if (mockOn) return mocked({ ...mockNotifyEvents })
return request('/settings/notify-events')
@@ -147,6 +181,17 @@ export function getAbout(): Promise<AboutInfo> {
buildTime: '2026-07-09T12:00Z',
goVersion: 'go1.26.4',
platform: 'linux/amd64',
startedAt: '2026-07-01T08:23:00+08:00',
uptimeSeconds: 1053360, // 12 天 4 小时 36 分
resources: {
cpuAvgPercent: 1.4,
numCpu: 4,
goroutines: 36,
memHeapBytes: 50537000,
memSysBytes: 101187000,
dbEngine: 'sqlite',
dbBytes: 13212000,
},
})
return request('/about')
}
+72 -52
View File
@@ -14,6 +14,7 @@ import { mockOn, mocked, request } from './request'
import type {
AuditEventsResult,
IamUser,
IdentityDomain,
IamUserDetail,
IdentityProvider,
IdentitySetting,
@@ -83,14 +84,25 @@ export function getSubscription(id: number, subscriptionId: string): Promise<Sub
return request(`/oci-configs/${id}/subscriptions/${subscriptionId}`)
}
// ---- 身份域 ----
/** 租户 ACTIVE 身份域列表;空数组表示无域老租户(隐藏选择器走经典路径) */
export function listIdentityDomains(id: number): Promise<IdentityDomain[]> {
if (mockOn)
return mocked([
{ id: 'ocid1.domain.default', displayName: 'Default', homeRegion: 'us-phoenix-1', type: 'DEFAULT', licenseType: 'free' },
{ id: 'ocid1.domain.idcs', displayName: 'OracleIdentityCloudService', homeRegion: 'us-phoenix-1', type: 'SECONDARY', licenseType: 'idcs-foundation' },
])
return request(`/oci-configs/${id}/domains`)
}
// ---- 用户 ----
export function listUsers(id: number): Promise<IamUser[]> {
export function listUsers(id: number, domainId?: string): Promise<IamUser[]> {
if (mockOn) return mocked(mockUsers)
return request(`/oci-configs/${id}/users`)
return request(`/oci-configs/${id}/users`, { query: { domainId } })
}
// 编辑表单预填充用;不在身份域的经典用户返回 inDomain: false
export function getUserDetail(id: number, userId: string): Promise<IamUserDetail> {
export function getUserDetail(id: number, userId: string, domainId?: string): Promise<IamUserDetail> {
if (mockOn)
return mocked(
mockUserDetails[userId] ?? {
@@ -101,7 +113,7 @@ export function getUserDetail(id: number, userId: string): Promise<IamUserDetail
inAdminGroup: false,
},
)
return request(`/oci-configs/${id}/users/${userId}`)
return request(`/oci-configs/${id}/users/${userId}`, { query: { domainId } })
}
export interface CreateUserBody {
@@ -115,10 +127,10 @@ export interface CreateUserBody {
addToAdminGroup?: boolean
}
export function createUser(id: number, body: CreateUserBody): Promise<IamUser> {
export function createUser(id: number, body: CreateUserBody, domainId?: string): Promise<IamUser> {
if (mockOn)
return mocked({ ...mockUsers[2], id: 'ocid1.user..new', name: body.name, email: body.email ?? '' })
return request(`/oci-configs/${id}/users`, { method: 'POST', body })
return request(`/oci-configs/${id}/users`, { method: 'POST', body, query: { domainId } })
}
export interface UpdateUserBody {
@@ -131,29 +143,29 @@ export interface UpdateUserBody {
addToAdminGroup?: boolean
}
export function updateUser(id: number, userId: string, body: UpdateUserBody): Promise<IamUser> {
export function updateUser(id: number, userId: string, body: UpdateUserBody, domainId?: string): Promise<IamUser> {
if (mockOn) {
const u = mockUsers.find((x) => x.id === userId) ?? mockUsers[2]
if (body.description !== undefined) u.description = body.description
if (body.email !== undefined) u.email = body.email
return mocked({ ...u })
}
return request(`/oci-configs/${id}/users/${userId}`, { method: 'PUT', body })
return request(`/oci-configs/${id}/users/${userId}`, { method: 'PUT', body, query: { domainId } })
}
export function deleteUser(id: number, userId: string): Promise<void> {
export function deleteUser(id: number, userId: string, domainId?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/users/${userId}`, { method: 'DELETE' })
return request(`/oci-configs/${id}/users/${userId}`, { method: 'DELETE', query: { domainId } })
}
export function resetUserPassword(id: number, userId: string): Promise<{ password: string }> {
export function resetUserPassword(id: number, userId: string, domainId?: string): Promise<{ password: string }> {
if (mockOn) return mocked({ password: 'Xy9#kQ2m$Lp8' })
return request(`/oci-configs/${id}/users/${userId}/reset-password`, { method: 'POST' })
return request(`/oci-configs/${id}/users/${userId}/reset-password`, { method: 'POST', query: { domainId } })
}
export function clearUserMfa(id: number, userId: string): Promise<{ deletedTotpDevices: number }> {
export function clearUserMfa(id: number, userId: string, domainId?: string): Promise<{ deletedTotpDevices: number }> {
if (mockOn) return mocked({ deletedTotpDevices: 1 })
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE' })
return request(`/oci-configs/${id}/users/${userId}/mfa-devices`, { method: 'DELETE', query: { domainId } })
}
export function deleteUserApiKeys(
@@ -169,26 +181,26 @@ export function deleteUserApiKeys(
}
// ---- 审计日志 ----
/** 实时查询租户 OCI 审计事件:hours ∈ [1,72] 首查;
* 截断续查传上次响应的 start/end 与 nextPage(绝对窗+游标断点续翻) */
/** 批式懒加载查询审计事件:cursor 空自当前时刻首查,
* 非空从上次响应游标向更早续取一批(~200 条,服务端分窗回溯);
* q 为服务端全文检索关键字,仅首查生效(续查沿用游标内嵌关键字) */
export function listAuditEvents(
id: number,
opts: { hours?: number; region?: string; start?: string; end?: string; page?: string } = {},
opts: { region?: string; cursor?: string; limit?: number; q?: string } = {},
): Promise<AuditEventsResult> {
// mock 下选 1 小时窗演示截断提示
if (mockOn)
return mocked(
{
items: mockAuditEvents,
truncated: opts.hours === 1,
start: '2026-07-06T10:00:00Z',
end: '2026-07-07T10:00:00Z',
},
300,
)
const { hours = 24, region, start, end, page } = opts
const query = start && end ? { region, start, end, page } : { hours, region }
return request(`/oci-configs/${id}/audit-events`, { query })
// 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 } })
}
/** 取回单条审计事件的原始 JSON;404 表示缓存过期且重查未找回(前端降级展示精简字段) */
@@ -201,45 +213,49 @@ export function getAuditEventDetail(
}
// ---- 域设置 ----
export function getNotificationRecipients(id: number): Promise<NotificationRecipients> {
export function getNotificationRecipients(id: number, domainId?: string): Promise<NotificationRecipients> {
if (mockOn) return mocked(mockRecipients)
return request(`/oci-configs/${id}/notification-recipients`)
return request(`/oci-configs/${id}/notification-recipients`, { query: { domainId } })
}
export function updateNotificationRecipients(
id: number,
recipients: string[],
domainId?: string,
): Promise<NotificationRecipients> {
if (mockOn) return mocked({ recipients, testModeEnabled: recipients.length > 0 })
return request(`/oci-configs/${id}/notification-recipients`, {
method: 'PUT',
body: { recipients },
query: { domainId },
})
}
export function listPasswordPolicies(id: number): Promise<PasswordPolicy[]> {
export function listPasswordPolicies(id: number, domainId?: string): Promise<PasswordPolicy[]> {
if (mockOn) return mocked(mockPolicies)
return request(`/oci-configs/${id}/password-policies`)
return request(`/oci-configs/${id}/password-policies`, { query: { domainId } })
}
export function updatePasswordPolicy(
id: number,
policyId: string,
body: { passwordExpiresAfter?: number; minLength?: number; numPasswordsInHistory?: number },
domainId?: string,
): Promise<PasswordPolicy> {
if (mockOn) return mocked({ ...mockPolicies[0], ...body })
return request(`/oci-configs/${id}/password-policies/${policyId}`, { method: 'PUT', body })
return request(`/oci-configs/${id}/password-policies/${policyId}`, { method: 'PUT', body, query: { domainId } })
}
// ---- 身份设置 ----
export function getIdentitySetting(id: number): Promise<IdentitySetting> {
export function getIdentitySetting(id: number, domainId?: string): Promise<IdentitySetting> {
if (mockOn) return mocked({ ...mockIdentitySetting })
return request(`/oci-configs/${id}/identity-settings`)
return request(`/oci-configs/${id}/identity-settings`, { query: { domainId } })
}
export function updateIdentitySetting(
id: number,
primaryEmailRequired: boolean,
domainId?: string,
): Promise<IdentitySetting> {
if (mockOn) {
mockIdentitySetting.primaryEmailRequired = primaryEmailRequired
@@ -248,10 +264,11 @@ export function updateIdentitySetting(
return request(`/oci-configs/${id}/identity-settings`, {
method: 'PUT',
body: { primaryEmailRequired },
query: { domainId },
})
}
export async function downloadSamlMetadata(id: number): Promise<void> {
export async function downloadSamlMetadata(id: number, domainId?: string): Promise<void> {
if (mockOn) {
const blob = new Blob(['<mock-saml-metadata/>'], { type: 'application/xml' })
triggerDownload(blob, 'oci-domain-saml-metadata.xml')
@@ -259,7 +276,8 @@ export async function downloadSamlMetadata(id: number): Promise<void> {
}
const { useAuthStore } = await import('@/stores/auth')
const auth = useAuthStore()
const resp = await fetch(`/api/v1/oci-configs/${id}/saml-metadata`, {
const qs = domainId ? `?domainId=${encodeURIComponent(domainId)}` : ''
const resp = await fetch(`/api/v1/oci-configs/${id}/saml-metadata${qs}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : {},
})
if (!resp.ok) throw new Error(`下载失败(${resp.status}`)
@@ -293,7 +311,7 @@ export interface CreateIdpBody {
jitMapEmail?: boolean
}
export function createIdp(id: number, body: CreateIdpBody): Promise<IdentityProvider> {
export function createIdp(id: number, body: CreateIdpBody, domainId?: string): Promise<IdentityProvider> {
if (mockOn) {
const created = {
...mockIdps[1],
@@ -304,41 +322,43 @@ export function createIdp(id: number, body: CreateIdpBody): Promise<IdentityProv
mockIdps.push(created)
return mocked(created, 900)
}
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body })
return request(`/oci-configs/${id}/identity-providers`, { method: 'POST', body, query: { domainId } })
}
export function listIdps(id: number): Promise<IdentityProvider[]> {
export function listIdps(id: number, domainId?: string): Promise<IdentityProvider[]> {
if (mockOn) return mocked(mockIdps)
return request(`/oci-configs/${id}/identity-providers`)
return request(`/oci-configs/${id}/identity-providers`, { query: { domainId } })
}
export function activateIdp(id: number, idpId: string, enabled: boolean): Promise<IdentityProvider> {
export function activateIdp(id: number, idpId: string, enabled: boolean, domainId?: string): Promise<IdentityProvider> {
if (mockOn) return mocked({ ...mockIdps[0], enabled })
return request(`/oci-configs/${id}/identity-providers/${idpId}/activate`, {
method: 'POST',
body: { enabled },
query: { domainId },
})
}
export function deleteIdp(id: number, idpId: string): Promise<void> {
export function deleteIdp(id: number, idpId: string, domainId?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/identity-providers/${idpId}`, { method: 'DELETE' })
return request(`/oci-configs/${id}/identity-providers/${idpId}`, { method: 'DELETE', query: { domainId } })
}
export function listSignOnRules(id: number): Promise<SignOnRule[]> {
export function listSignOnRules(id: number, domainId?: string): Promise<SignOnRule[]> {
if (mockOn) return mocked(mockSignOnRules)
return request(`/oci-configs/${id}/sign-on-rules`)
return request(`/oci-configs/${id}/sign-on-rules`, { query: { domainId } })
}
export function createSignOnExemption(id: number, identityProviderId: string): Promise<SignOnRule> {
export function createSignOnExemption(id: number, identityProviderId: string, domainId?: string): Promise<SignOnRule> {
if (mockOn) return mocked(mockSignOnRules[0])
return request(`/oci-configs/${id}/sign-on-exemptions`, {
method: 'POST',
body: { identityProviderId },
query: { domainId },
})
}
export function deleteSignOnExemption(id: number, ruleId: string): Promise<void> {
export function deleteSignOnExemption(id: number, ruleId: string, domainId?: string): Promise<void> {
if (mockOn) return mocked(undefined)
return request(`/oci-configs/${id}/sign-on-exemptions/${ruleId}`, { method: 'DELETE' })
return request(`/oci-configs/${id}/sign-on-exemptions/${ruleId}`, { method: 'DELETE', query: { domainId } })
}
+167 -1
View File
@@ -25,6 +25,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 同步 */
@@ -81,6 +84,42 @@ 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);
}
/* 租户选择器面板(移动端):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);
@@ -136,7 +175,9 @@ body {
width: 26px;
margin: 0;
display: grid;
grid-template-rows: 1fr 1fr;
/* minmax(0,…) 允许行收缩:small 尺寸下按钮固有高度(18px)大于半行,
1fr 的隐式 min-height:auto 会把行撑爆,下箭头溢出框外 */
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
border-left: 1px solid var(--color-line-soft);
}
.app-input-number .n-input__suffix > :last-child {
@@ -158,6 +199,7 @@ body {
--n-text-color-focus: var(--color-ink);
width: 100%;
height: 100%;
min-height: 0;
border-radius: inherit;
color: var(--color-ink-3);
}
@@ -165,6 +207,19 @@ body {
background: var(--color-wash);
color: var(--color-ink);
}
/* clearable 时 naive-ui 在 suffix 首位多渲染 .n-base-clear,会被上面的
2 行网格自动排进隐式第 2 列、把 26px 竖栏撑爆(箭头溢出框外)——
绝对定位使其脱离网格流,悬浮在竖栏左侧;文本区同步加宽避让清空图标 */
.app-input-number .n-input__suffix .n-base-clear {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
margin-right: 5px;
}
.app-input-number .n-input:has(.n-base-clear) .n-input-wrapper {
padding-right: 52px;
}
/* useToast 可展开详情:toast 内嵌代码块(标签 + 复制 + 内滚 pre)。
message 经 teleport 渲染于 body,组件 scoped 样式不可达,故置于全局。 */
@@ -258,3 +313,114 @@ body {
}
}
}
/* 明暗主题扩散切换(useThemeRipple):关掉 View Transitions 默认交叉淡入,
由 JS 在 ::view-transition-new(root) 上做以触发点为圆心的 clip-path 揭示;
旧快照垫底不动,blend 还原为普通覆盖,避免半透明混色。 */
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
/* 扩散进行中禁掉页面自身的过渡:new 快照是实时层,Naive 等组件的
0.3s 颜色渐变会在扩散圆内继续播放,叠加揭示动画表现为闪烁/渗色。 */
html.theme-switching *,
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);
}
+87 -19
View File
@@ -1,30 +1,98 @@
<script setup lang="ts">
// 项目 logo:星爆贴纸(波普风,十二角星爆 + O!)。
// 固定品牌色值不走 CSS 变量——favicon 与深浅主题共用同一套颜色。
// 项目 logo:波普贴纸,双款式跟随 app store(burst 星爆 / seal 波浪封印,
// 设置 · 关于点击 logo 切换)。固定品牌色值不走 CSS 变量——favicon 与深浅主题共用同一套颜色。
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
withDefaults(defineProps<{ size?: number }>(), { size: 48 })
const BURST =
'M24 5 L27.9 9.5 L33.5 7.5 L34.6 13.4 L40.5 14.5 L38.5 20.1 L43 24 L38.5 27.9 ' +
'L40.5 33.5 L34.6 34.6 L33.5 40.5 L27.9 38.5 L24 43 L20.1 38.5 L14.5 40.5 L13.4 34.6 ' +
'L7.5 33.5 L9.5 27.9 L5 24 L9.5 20.1 L7.5 14.5 L13.4 13.4 L14.5 7.5 L20.1 9.5 Z'
// 十二瓣扇贝圆(经典价签贴纸),内圈虚线封印
const SEAL =
'M39.5 19.9 A5.8 5.8 0 0 0 35.3 12.7 A5.8 5.8 0 0 0 28.1 8.5 A5.8 5.8 0 0 0 19.9 8.5 ' +
'A5.8 5.8 0 0 0 12.7 12.7 A5.8 5.8 0 0 0 8.5 19.9 A5.8 5.8 0 0 0 8.5 28.1 ' +
'A5.8 5.8 0 0 0 12.7 35.3 A5.8 5.8 0 0 0 19.9 39.5 A5.8 5.8 0 0 0 28.1 39.5 ' +
'A5.8 5.8 0 0 0 35.3 35.3 A5.8 5.8 0 0 0 39.5 28.1 A5.8 5.8 0 0 0 39.5 19.9 Z'
const VARIANTS = {
burst: { d: BURST, strokeWidth: 2.6, textY: 28.8, fontSize: 13, ring: false },
seal: { d: SEAL, strokeWidth: 2.4, textY: 28.6, fontSize: 12.5, ring: true },
} as const
const app = useAppStore()
const v = computed(() => VARIANTS[app.logoVariant])
</script>
<template>
<svg :width="size" :height="size" viewBox="0 0 48 48" aria-hidden="true">
<g transform="rotate(-8 24 24)">
<path :d="BURST" fill="#3A2418" transform="translate(1.9,2.4)"></path>
<path :d="BURST" fill="#C96442" stroke="#FFFDF7" stroke-width="2.6" stroke-linejoin="round"></path>
<text
x="24"
y="28.8"
text-anchor="middle"
font-family="Georgia,'Times New Roman',serif"
font-size="13"
font-weight="900"
fill="#FAF3E3"
>
O!
</text>
</g>
</svg>
<span class="logo-wrap" :style="{ width: `${size}px`, height: `${size}px` }">
<Transition name="logo-slide">
<svg :key="app.logoVariant" :width="size" :height="size" viewBox="0 0 48 48" aria-hidden="true">
<g transform="rotate(-8 24 24)">
<path :d="v.d" fill="#3A2418" transform="translate(1.9,2.4)"></path>
<path :d="v.d" fill="#C96442" stroke="#FFFDF7" :stroke-width="v.strokeWidth" stroke-linejoin="round"></path>
<circle
v-if="v.ring"
cx="24"
cy="24"
r="12.5"
fill="none"
stroke="#FAF3E3"
stroke-width="1"
stroke-dasharray="2.4 2.6"
opacity="0.7"
></circle>
<text
x="24"
:y="v.textY"
text-anchor="middle"
font-family="Georgia,'Times New Roman',serif"
:font-size="v.fontSize"
font-weight="900"
fill="#FAF3E3"
>
O!
</text>
</g>
</svg>
</Transition>
</span>
</template>
<style scoped>
/* 款式切换:旧款右滑淡出、新款自左滑入(整体从左到右);
离开元素绝对定位避免过渡期双元素撑开布局 */
.logo-wrap {
position: relative;
display: inline-block;
line-height: 0;
}
.logo-slide-enter-active,
.logo-slide-leave-active {
transition:
transform 0.35s ease,
opacity 0.35s ease;
}
.logo-slide-leave-active {
position: absolute;
left: 0;
top: 0;
}
.logo-slide-enter-from {
transform: translateX(-45%);
opacity: 0;
}
.logo-slide-leave-to {
transform: translateX(45%);
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.logo-slide-enter-active,
.logo-slide-leave-active {
transition: none;
}
}
</style>
+83
View File
@@ -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>
+144
View File
@@ -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>
+11 -5
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
/** 租户失联占位:租户详情各 tab 与实例 / 网络 / 存储等资源页在
* 当前租户失联时整体替换内容区,避免各处「无数据」/ 报错造成误判。 */
defineProps<{ lastError?: string }>()
/** 租户失联/暂停占位:租户详情各 tab 与实例 / 网络 / 存储等资源页在
* 当前租户不可用时整体替换内容区,避免各处「无数据」/ 报错造成误判。 */
defineProps<{ lastError?: string; suspended?: boolean }>()
</script>
<template>
@@ -23,12 +23,18 @@ defineProps<{ lastError?: string }>()
<line x1="16" y1="19" x2="16" y2="22" />
<line x1="19" y1="16" x2="22" y2="16" />
</svg>
<div class="text-sm font-semibold text-ink-2">租户已失联</div>
<div class="text-sm font-semibold text-ink-2">
{{ suspended ? '租户已被云端暂停' : '租户已失联' }}
</div>
<div v-if="lastError" class="mono max-w-130 text-xs break-all whitespace-pre-wrap text-ink-3">
{{ lastError }}
</div>
<div class="max-w-95 text-[12.5px] text-ink-3">
请检查 API Key 是否有效修复后在租户页重新测活
{{
suspended
? '账户被 Oracle 暂停(欠费 / 封禁 / 终止),请到官方控制台或工单处理,恢复后重新测活'
: '请检查 API Key 是否有效,修复后在租户页重新测活'
}}
</div>
</div>
</template>
+2 -2
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import { computed, ref } from 'vue'
import { useToast } from '@/composables/useToast'
/** 拖拽或点击上传的文本文件选择区;文件内容仅在浏览器本地读取。
* compact:单行紧凑形态,用于表单内与其他字段并排。 */
const props = defineProps<{ accept?: string; label?: string; hint?: string; compact?: boolean }>()
const emit = defineEmits<{ load: [text: string, name: string] }>()
const message = useMessage()
const message = useToast()
const inputEl = ref<HTMLInputElement>()
const fileName = ref('')
const dragging = ref(false)
+33
View File
@@ -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>
+34 -5
View File
@@ -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,7 +31,8 @@ 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"
@update:show="emit('update:show', $event)"
@@ -33,11 +46,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 +73,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 复用 -->
+3 -2
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { NButton, useMessage } from 'naive-ui'
import { NButton } from 'naive-ui'
import { computed } from 'vue'
import { useToast } from '@/composables/useToast'
/** 统一的 JSON 展示块:美化缩进 + 语法高亮 + 复制;三个日志详情共用。
* 传 title 时渲染工具栏头(左标签 + meta + 右复制按钮),否则复制按钮浮动。
@@ -14,7 +15,7 @@ const props = defineProps<{
wide?: boolean
}>()
const message = useMessage()
const message = useToast()
const pretty = computed(() => {
const v = props.value
+28
View File
@@ -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>
+3 -2
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NButton, NIcon, useMessage } from 'naive-ui'
import { NButton, NIcon } from 'naive-ui'
import { truncOcid } from '@/composables/useFormat'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ ocid: string }>()
const message = useMessage()
const message = useToast()
async function copy() {
await navigator.clipboard.writeText(props.ocid)
-16
View File
@@ -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>
+25
View File
@@ -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>
+57
View File
@@ -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>
+7 -2
View File
@@ -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>
+204
View File
@@ -0,0 +1,204 @@
<script setup lang="ts">
import { NButton, NInput, NModal, useDialog } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
addAiBlacklist,
listAiChannelModels,
syncAiChannelModels,
testAiChannelModel,
} from '@/api/aigateway'
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 dialog = useDialog()
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 = ''
}
}
function confirmBlacklist(name: string) {
dialog.warning({
title: '拉黑模型',
content: `${name}」将从全部渠道剔除,同步与探测不再入库;可在模型黑名单中移出后重新同步恢复。`,
positiveText: '拉黑',
negativeText: '取消',
onPositiveClick: async () => {
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>
<button
type="button"
class="flex-none cursor-pointer rounded-[5px] px-1.5 py-0.5 text-xs text-ink-3/70 group-hover:text-err hover:bg-err/10"
@click="confirmBlacklist(r.name)"
>
拉黑
</button>
</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>
+58 -33
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, 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 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'
@@ -15,13 +19,28 @@ import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
import type { AiChannel, AiProbeStatus } from '@/types/api'
defineProps<{ channels: AiChannel[]; loading: boolean }>()
const props = defineProps<{ channels: AiChannel[]; loading: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
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: '可用' },
@@ -67,7 +86,7 @@ async function submit() {
try {
if (editing.value) {
await updateAiChannel(editing.value.id, { name: form.name, group: form.group, priority: form.priority, weight: form.weight })
message.success('已更新渠道')
toast.success('已更新渠道')
showForm.value = false
emit('changed')
} else {
@@ -77,11 +96,11 @@ async function submit() {
})
showForm.value = false
emit('changed')
message.success('渠道已添加,正在探测…')
toast.success('渠道已添加,正在探测…')
void doProbe(ch.id)
}
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
@@ -95,35 +114,34 @@ async function doProbe(id: number) {
const ch = await probeAiChannel(id)
const meta = ch.probeStatus ? PROBE_META[ch.probeStatus as Exclude<AiProbeStatus, ''>] : null
if (ch.probeStatus === 'ok') {
message.success('探测完成:可用')
toast.success('探测完成:可用')
} else {
toast.warning(`探测完成:${meta?.label ?? ch.probeStatus}`, ch.probeError || undefined)
}
} catch (e) {
message.error(e instanceof Error ? e.message : '探测失败')
toast.error(e instanceof Error ? e.message : '探测失败')
} finally {
probing.value.delete(id)
emit('changed')
}
}
async function doSync(id: number) {
try {
const models = await syncAiChannelModels(id)
message.success(`已同步 ${models.length} 个模型`)
emit('changed')
} catch (e) {
message.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
}
async function toggle(ch: AiChannel) {
try {
await updateAiChannel(ch.id, { enabled: !ch.enabled })
message.success(ch.enabled ? '已停用' : '已启用')
toast.success(ch.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
@@ -135,7 +153,7 @@ function confirmRemove(ch: AiChannel) {
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiChannel(ch.id)
message.success('已删除')
toast.success('已删除')
emit('changed')
},
})
@@ -166,7 +184,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,
@@ -176,12 +194,16 @@ 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, type: 'primary', onClick: () => openModels(r) }, { default: () => '模型列表' }),
h(NButton, { size: 'tiny', quaternary: true, 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: () => '删除' }),
@@ -192,21 +214,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 ? '编辑渠道' : '添加渠道'"
@@ -234,6 +258,7 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
<div class="grid grid-cols-3 gap-3">
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
<NInput v-model:value="form.group" placeholder="默认" />
<GroupChips :groups="groupHints" @pick="form.group = $event" />
</FormField>
<FormField label="优先级" hint="数字小优先">
<AppInputNumber v-model:value="form.priority" :min="1" />
+55 -30
View File
@@ -1,39 +1,45 @@
<script setup lang="ts">
import { NButton, NDataTable, NInput, useDialog, useMessage, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiKey, deleteAiKey, updateAiKey, updateAiKeyContentLog } from '@/api/aigateway'
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 } from '@/types/api'
import type { AiKey, AiModel } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[] }>()
const props = defineProps<{ keys: AiKey[]; loading: boolean; groups: string[]; models: AiModel[] }>()
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
const message = useToast()
const dialog = useDialog()
// ---- / ----
const showForm = ref(false)
const editing = ref<AiKey | null>(null)
const submitting = ref(false)
const form = reactive({ name: '', value: '', group: '' })
const form = reactive({ name: '', value: '', group: '', models: [] as string[] })
function openCreate() {
editing.value = null
Object.assign(form, { name: '', value: '', group: '' })
Object.assign(form, { name: '', value: '', group: '', models: [] })
showForm.value = true
}
function openEdit(key: AiKey) {
editing.value = key
Object.assign(form, { name: key.name, value: '', group: key.group })
Object.assign(form, { name: key.name, value: '', group: key.group, models: key.models ?? [] })
showForm.value = true
}
/** 限制模型下拉选项:聚合模型列表;tag 模式允许手输未同步模型 */
const modelOptions = computed(() => props.models.map((m) => ({ label: m.id, value: m.id })))
//
const createdKey = ref('')
const showCreated = ref(false)
@@ -42,10 +48,13 @@ async function submit() {
submitting.value = true
try {
if (editing.value) {
await updateAiKey(editing.value.id, { name: form.name, group: form.group })
await updateAiKey(editing.value.id, { name: form.name, group: form.group, models: form.models })
message.success('已更新密钥')
} else {
const { key } = await createAiKey({ name: form.name, value: form.value || undefined, group: form.group || undefined })
const { key } = await createAiKey({
name: form.name, value: form.value || undefined, group: form.group || undefined,
models: form.models.length ? form.models : undefined,
})
createdKey.value = key
showCreated.value = true
}
@@ -117,6 +126,21 @@ function confirmRemove(key: AiKey) {
const groupHints = computed(() => props.groups.filter(Boolean))
// modelsCell : = ;,
function modelsCell(r: AiKey) {
const list = r.models ?? []
if (!list.length) return h('span', { class: 'text-xs text-ink-3' }, '不限')
return h(
NTooltip,
{ style: { maxWidth: '320px' } },
{
trigger: () =>
h('span', { class: 'cursor-help rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, `${list.length}`),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, list.join('\n')),
},
)
}
const columns = computed<DataTableColumns<AiKey>>(() => [
{ title: '名称', key: 'name', width: 220, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
{ title: '密钥', key: 'tail', width: 120, render: (r) => h('span', { class: 'mono text-[13px] text-ink-2' }, `……${r.tail}`) },
@@ -126,6 +150,7 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
? h('span', { class: 'rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, r.group)
: h('span', { class: 'text-xs text-ink-3' }, '不限'),
},
{ title: '模型', key: 'models', width: 90, render: modelsCell },
{
title: '状态', key: 'enabled', width: 100,
render: (r) => h(StatusBadge, r.enabled ? { kind: 'run', label: '启用' } : { kind: 'stop', label: '已禁用' }),
@@ -145,16 +170,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"
@@ -169,18 +191,21 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
</FormField>
<FormField label="渠道分组" hint="选择分组后,该密钥的调用只在该分组的渠道内负载均衡;留空不限分组">
<NInput v-model:value="form.group" placeholder="留空 = 不限(全部渠道)" />
<div v-if="groupHints.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span class="text-xs text-ink-3">现有分组:</span>
<button
v-for="g in groupHints"
:key="g"
type="button"
class="cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-xs text-ink-2 hover:border-accent hover:text-accent"
@click="form.group = g"
>
{{ g }}
</button>
</div>
<GroupChips :groups="groupHints" @pick="form.group = $event" />
</FormField>
<FormField
label="限制模型"
hint="非空时该密钥仅能调用所选模型,/v1/models 同步只返回交集;可手输未同步的模型名;留空不限"
>
<NSelect
v-model:value="form.models"
:options="modelOptions"
multiple
filterable
clearable
tag
placeholder="留空 = 不限(全部模型)"
/>
</FormField>
<FormField v-if="!editing" label="自定义密钥值" hint="至少 8 位;仅测试场景建议自定义">
<NInput v-model:value="form.value" placeholder="留空自动生成 sk- 开头随机密钥(推荐)" />
+20
View File
@@ -0,0 +1,20 @@
<script setup lang="ts">
// : chip ;
defineProps<{ groups: string[] }>()
defineEmits<{ pick: [group: string] }>()
</script>
<template>
<div v-if="groups.length" class="mt-1.5 flex flex-wrap items-center gap-1.5">
<span class="text-xs text-ink-3">现有分组:</span>
<button
v-for="g in groups"
:key="g"
type="button"
class="cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-xs text-ink-2 hover:border-accent hover:text-accent"
@click="$emit('pick', g)"
>
{{ g }}
</button>
</div>
</template>
+3 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage } from 'naive-ui'
import { NInput, NSelect } from 'naive-ui'
import { computed, reactive, watch } from 'vue'
import { ref } from 'vue'
@@ -12,11 +12,12 @@ import ToggleCard from '@/components/ToggleCard.vue'
import { useProxyOptions } from '@/composables/useProxyOptions'
import { useScopeStore } from '@/stores/scope'
import type { OciConfigSummary } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ show: boolean; config: OciConfigSummary | null }>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const form = reactive({
+3 -2
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NInput, NSelect, NTabPane, NTabs, useMessage } from 'naive-ui'
import { NInput, NSelect, NTabPane, NTabs } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { importConfig } from '@/api/configs'
@@ -11,11 +11,12 @@ import ToggleCard from '@/components/ToggleCard.vue'
import { useProxyOptions } from '@/composables/useProxyOptions'
import { useScopeStore } from '@/stores/scope'
import type { ImportConfigRequest } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ 'update:show': [boolean]; imported: [] }>()
const message = useMessage()
const message = useToast()
const mode = ref<'ini' | 'fields'>('ini')
const submitting = ref(false)
+47 -4
View File
@@ -1,14 +1,20 @@
<script setup lang="ts">
import { NInput, NSelect, useMessage, type SelectOption } from 'naive-ui'
import { NInput, NSelect, type SelectOption } from 'naive-ui'
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'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -21,17 +27,28 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const subnetId = ref<string | null>(null)
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) => {
@@ -41,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')
@@ -74,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) {
@@ -127,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"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NCheckbox, NSelect, useMessage, type SelectGroupOption } from 'naive-ui'
import { NCheckbox, NSelect, type SelectGroupOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
@@ -7,6 +7,7 @@ import { attachVolume, listVolumes } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -19,7 +20,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:show': [boolean]; attached: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const volumeId = ref<string | null>(null)
const readOnly = ref(false)
+116
View File
@@ -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>
+3 -2
View File
@@ -1,15 +1,16 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { NInput } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { createConsoleConnection } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ show: boolean; cfgId: number; instanceId: string; region?: string }>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const publicKey = ref('')
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NInput, useMessage } from 'naive-ui'
import { NInput } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createInstances } from '@/api/instances'
@@ -8,6 +8,7 @@ import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import InstanceSpecForm from '@/components/instance/InstanceSpecForm.vue'
import { defaultResourceName } from '@/composables/useFormat'
import { useToast } from '@/composables/useToast'
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
const props = defineProps<{
@@ -18,7 +19,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const specForm = ref<InstanceType<typeof InstanceSpecForm> | null>(null)
@@ -43,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, {
@@ -50,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
}
@@ -89,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>
@@ -1,6 +1,4 @@
<script setup lang="ts">
import { useMessage } from 'naive-ui'
import AppInputNumber from '@/components/AppInputNumber.vue'
import { reactive, ref, watch } from 'vue'
@@ -8,6 +6,7 @@ import { updateBootVolume } from '@/api/bootVolumes'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import type { BootVolume } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -17,7 +16,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ 'update:show': [boolean]; updated: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const form = reactive({ sizeInGBs: 50, vpusPerGB: 10 })
+139 -36
View File
@@ -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)),
}
})
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { NSelect } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listBootVolumes } from '@/api/bootVolumes'
@@ -7,6 +7,7 @@ import { replaceBootVolume } from '@/api/instances'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -18,7 +19,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:show': [boolean]; replaced: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const bootVolumeId = ref<string | null>(null)
+5 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NSelect, useMessage } from 'naive-ui'
import { NSelect } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { listShapes, updateInstance } from '@/api/instances'
@@ -7,6 +7,7 @@ import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -20,7 +21,7 @@ const props = defineProps<{
const emit = defineEmits<{ 'update:show': [boolean]; resized: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const form = reactive({ shape: props.shape, ocpus: props.ocpus, memoryInGBs: props.memoryInGBs })
@@ -36,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(
@@ -140,7 +133,7 @@ async function submit() {
</div>
<div class="text-xs leading-relaxed text-ink-3">
运行中实例 OCI 会自动重启完成变更更换 shape 要求镜像与目标 shape 架构兼容 aarch64
镜像不能换到 x86 shapeA1 免费额度上限 4 OCPU / 24 GB
镜像不能换到 x86 shape
</div>
</FormModal>
</template>
+5 -12
View File
@@ -2,11 +2,12 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import '@xterm/xterm/css/xterm.css'
import { NSpin, useMessage } from 'naive-ui'
import { NSpin } from 'naive-ui'
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { consoleSessionWsUrl, createConsoleSession, deleteConsoleSession } from '@/api/instances'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
show: boolean
@@ -14,12 +15,11 @@ const props = defineProps<{
instanceId: string
region?: string
instanceName?: string
rootPassword?: string
}>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
const message = useMessage()
const message = useToast()
const auth = useAuthStore()
const termEl = ref<HTMLDivElement | null>(null)
@@ -153,12 +153,6 @@ function syncWinsize() {
term.focus()
}
async function copyRootPassword() {
await navigator.clipboard.writeText(props.rootPassword ?? '')
message.success('已复制 root 密码')
term?.focus()
}
function reconnect() {
teardown()
void open()
@@ -250,7 +244,6 @@ onBeforeUnmount(teardown)
</span>
<span v-if="phase === 'creating'" class="text-xs">准备连接中</span>
<div class="flex-1" />
<button v-if="rootPassword" class="ser-btn" @click="copyRootPassword">复制 root 密码</button>
<button class="ser-btn" :disabled="phase !== 'connected'" @click="pasteFromClipboard">
粘贴
</button>
@@ -299,8 +292,8 @@ onBeforeUnmount(teardown)
<template v-else-if="phase === 'closed'">
<div class="max-w-[80%] break-all text-center">{{ error || '连接已关闭' }}</div>
<div class="max-w-[80%] text-center text-xs leading-relaxed">
串口登录需实例本地账户密码SSH 公钥无效创建实例时若启用随机 root
密码可从顶栏复制无密码也可查看启动日志或经 GRUB 单用户模式救援
串口登录需实例本地账户密码SSH 公钥无效随机 root 密码可在实例详情
验证身份后查看无密码也可查看启动日志或经 GRUB 单用户模式救援
</div>
<button class="ser-btn !border !border-[#3a3835] !px-3 !py-1" @click="reconnect">
重新连接
+101 -16
View File
@@ -1,15 +1,19 @@
<script setup lang="ts">
import { NButton, NPopconfirm, NSpin, useMessage } 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 接口 */
@@ -23,15 +27,57 @@ const props = defineProps<{
const emit = defineEmits<{ changed: [] }>()
const message = useMessage()
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('')
@@ -43,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 {
@@ -91,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">
@@ -114,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>
@@ -143,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"
@@ -203,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>
@@ -219,8 +296,7 @@ defineExpose({ refresh: () => vnics.run() })
</div>
<FootNote>
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
可多个要求所在子网已启用 IPv6· 次要网卡在 OS 内需自行配置 IPdhclient / netplan
附加须实例运行中且 shape 有空余 VNIC 配额
</FootNote>
<AttachVnicModal
@@ -229,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>
+20 -9
View File
@@ -1,16 +1,22 @@
<script setup lang="ts">
import { NDataTable, NModal, NTooltip, useMessage, type DataTableColumns } from 'naive-ui'
import { NDataTable, NModal, NTooltip, type DataTableColumns } from 'naive-ui'
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 = useMessage()
const message = useToast()
const isMobile = useIsMobile()
const { modalStyle, modalClass } = useMobileModal(600)
const rows = ref<AiCallLog[]>([])
const loading = ref(false)
@@ -20,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()
@@ -68,7 +78,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> = [
@@ -78,7 +88,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)) },
]
@@ -193,10 +203,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"
@@ -205,10 +215,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"
+41 -52
View File
@@ -1,28 +1,30 @@
<script setup lang="ts">
import { NDataTable, NModal, NPopover, useMessage, 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 message = useMessage()
const router = useRouter()
const isMobile = useIsMobile()
const { modalStyle, modalClass } = useMobileModal(720)
const message = useToast()
const scope = useScopeStore()
const { alias: regionAlias } = useRegionAlias()
const rows = ref<LogEvent[]>([])
const loading = ref(false)
@@ -47,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()
@@ -86,35 +92,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,
})
}
/** 摘要头标题:事件中文名 → 类型尾段 → 解析状态兜底 */
@@ -166,9 +154,10 @@ const columns: DataTableColumns<LogEvent> = [
render: renderTenant,
},
{
// : 45 ,
title: '事件类型',
key: 'eventType',
minWidth: 280,
width: 400,
ellipsis: { tooltip: true },
render: (row) =>
row.eventType
@@ -185,9 +174,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 || '—'),
},
@@ -210,21 +200,28 @@ 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>
<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"
@@ -237,18 +234,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/UpdateUserCreate/DeleteApiKeyUpdateUserCapabilities)
区域订阅与策略变更等关键审计事件将自动回传;消息按 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"
+28 -20
View File
@@ -1,19 +1,24 @@
<script setup lang="ts">
import { NDataTable, NInput, NModal, useMessage, type DataTableColumns } from 'naive-ui'
import { NDataTable, NInput, NModal, type DataTableColumns } from 'naive-ui'
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 message = useMessage()
const isMobile = useIsMobile()
const { modalStyle, modalClass } = useMobileModal(640)
const message = useToast()
const rows = ref<SystemLog[]>([])
const loading = ref(false)
@@ -28,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()
@@ -160,19 +169,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"
/>
@@ -184,17 +199,10 @@ useAutoRefresh(() => load(true))
:loading="loading"
:pagination="pagination"
:scroll-x="880"
:max-height="480"
: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"
+3 -2
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NCheckbox, NInput, useMessage } from 'naive-ui'
import { NCheckbox, NInput } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { createVcn } from '@/api/networks'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { defaultResourceName } from '@/composables/useFormat'
import { useToast } from '@/composables/useToast'
/** 租户 / 区间 / 区域直接使用父页面当前值,不在表单中出现 */
const props = defineProps<{
@@ -16,7 +17,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{ 'update:show': [boolean]; created: [] }>()
const message = useMessage()
const message = useToast()
const submitting = ref(false)
const blank = {
+250
View File
@@ -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,295 @@
<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"
dashed
: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,
])
}
+58
View File
@@ -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,900 @@
<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 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)
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), {
immediate: true,
})
/** 有对象取回中(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 download(name: string, size?: number) {
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
return doDownload(task, name, known)
}
async function doDownload(task: TransferTask, name: string, size: number) {
if (!props.cfgId) 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)
task.status = 'done'
task.percent = 100
// (1 ),
if (cleanable) void cleanupPar(par.id)
else parPanel.value?.refresh()
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return
task.status = 'error'
task.error = err instanceof Error ? err.message : '下载失败'
}
}
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
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)
}
}
function browserOpen(task: TransferTask, url: string): boolean {
window.open(url, '_blank')
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)
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
{ label: '1 天', value: 24 },
{ label: '7 天', value: 168 },
{ label: '30 天', value: 720 },
]
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() {
if (!props.cfgId || !parForm.expiresHours) return
parBusy.value = true
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: parForm.objectName,
accessType: parForm.accessType,
expiresHours: parForm.expiresHours,
})
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="small" @click="openParModal('')">分享桶</NButton>
<NButton size="small" @click="emit('settings')">桶设置</NButton>
<NButton size="small" 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" @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" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
<NButton size="tiny" :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 || !parForm.expiresHours"
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="OCI 上限 30 天(720 小时)">
<div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber
v-model:value="parForm.expiresHours"
:min="1"
:max="720"
:precision="0"
size="small"
class="!w-24"
/>
<span class="text-[12.5px] text-ink-2">小时</span>
<NButton
v-for="q in QUICK_HOURS"
:key="q.value"
size="tiny"
quaternary
:type="parForm.expiresHours === q.value ? 'primary' : 'default'"
@click="parForm.expiresHours = q.value"
>
{{ q.label }}
</NButton>
</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" @click="copyParUrl">复制</NButton>
</div>
<div v-if="parUrl" class="mt-1 text-xs text-warn">
链接仅本次展示,关闭后无法再次获取,请立即保存
</div>
</FormModal>
</div>
</template>
@@ -0,0 +1,527 @@
<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()
void detail.run().then(() => {
if (!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 = ''
}
async function loadContent() {
if (!props.cfgId || loading.value) return
loading.value = true
loadError.value = ''
try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
contentEtag.value = c.etag
contentType.value = c.contentType
await applyContent(c.blob)
} catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally {
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>
+207
View File
@@ -0,0 +1,207 @@
<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(
[() => props.cfgId, () => 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" :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
<NButton size="tiny" :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)
}
+60
View File
@@ -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>',
}
+61
View File
@@ -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,184 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
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">
<NButton
size="tiny"
:quaternary="renderMode !== 'render'"
:type="renderMode === 'render' ? 'primary' : 'default'"
@click="renderMode = 'render'"
>
渲染
</NButton>
<NButton
size="tiny"
:quaternary="renderMode !== 'code'"
:type="renderMode === 'code' ? 'primary' : 'default'"
@click="renderMode = 'code'"
>
代码
</NButton>
</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>
+55 -33
View File
@@ -3,11 +3,8 @@ import {
NButton,
NDataTable,
NInput,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
useMessage,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, onUnmounted, ref } from 'vue'
@@ -20,13 +17,18 @@ 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'
import type { ProxyImportResult, ProxyInfo } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useMessage()
const message = useToast()
const proxies = useAsync(listProxies)
const rows = computed(() => proxies.data.value?.items ?? [])
@@ -120,6 +122,15 @@ async function remove(row: ProxyInfo) {
}
}
// ---- ----
const showTenants = ref(false)
const tenantsFor = ref<ProxyInfo | null>(null)
function openTenants(row: ProxyInfo) {
tenantsFor.value = row
showTenants.value = true
}
// ---- ----
const probingIds = ref(new Set<number>())
@@ -179,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 })
@@ -204,16 +222,19 @@ 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',
width: 110,
render: (row) =>
h(
'span',
{ class: 'text-[12.5px] text-ink-2' },
row.username ? `${row.username}${row.passwordSet ? ' / ●●●' : ''}` : row.passwordSet ? '●●●' : '无',
),
width: 76,
render: (row) => {
const has = !!row.username || row.passwordSet
// ()
const title = has ? (row.username ? `用户名 ${row.username}` : '仅密码认证') : undefined
return h('span', { title }, [
h(StatusBadge, { kind: has ? 'run' : 'check', label: has ? '是' : '否', dot: false }),
])
},
},
{
title: '关联租户',
@@ -225,7 +246,7 @@ const columns: DataTableColumns<ProxyInfo> = [
{
title: '操作',
key: 'actions',
width: 168,
width: 236,
render: (row) =>
h('div', { class: 'flex items-center gap-1' }, [
h(
@@ -233,14 +254,14 @@ const columns: DataTableColumns<ProxyInfo> = [
{ size: 'tiny', quaternary: true, loading: probingIds.value.has(row.id), onClick: () => probe(row) },
() => '重测',
),
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: () => '删除后不可恢复,确认?',
},
),
]),
@@ -258,14 +279,21 @@ const columns: DataTableColumns<ProxyInfo> = [
:row-key="(r: ProxyInfo) => r.id"
/>
<FootNote>
支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储不回显明文;地区为经代理实测的出口位置(ip-api.com),创建后自动检测;被租户关联的代理不可删除,先解除关联
支持 SOCKS5 / HTTP / HTTPS;被租户关联的代理须先解除关联才可删除
</FootNote>
<NModal
<ProxyTenantsModal
v-model:show="showTenants"
:proxy="tenantsFor"
@saved="void proxies.run({ silent: true })"
/>
<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">
@@ -307,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"
@@ -325,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
@@ -377,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>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { NButton, NModal } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { listConfigs } from '@/api/configs'
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'
/** :,,
* (选中即关联,含从其他代理改挂;取消选中即解除) */
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)
watch(
() => props.show,
async (v) => {
if (!v || !props.proxy) return
selected.value = []
await configs.run()
const pid = props.proxy.id
selected.value = (configs.data.value ?? []).filter((c) => c.proxyId === pid).map((c) => c.id)
},
)
const all = computed(() => configs.data.value ?? [])
/** 当前选中名单(打开时即已关联名单),chips 展示并支持点 × 移除 */
const picked = computed(() => all.value.filter((c) => selected.value.includes(c.id)))
/** 选中但已挂其他代理的租户:保存即改挂,给出警示 */
const rebinding = computed(() =>
picked.value.filter((c) => c.proxyId !== null && c.proxyId !== props.proxy?.id),
)
function removeOne(id: number) {
selected.value = selected.value.filter((v) => v !== id)
}
async function save() {
if (!props.proxy) return
saving.value = true
try {
const res = await setProxyTenants(props.proxy.id, selected.value)
message.success(`已更新关联,当前 ${res.usedBy} 个租户经此代理出站`)
emit('update:show', false)
emit('saved')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="`关联租户 · ${proxy?.name ?? ''}`"
:style="modalStyle"
:class="modalClass"
@update:show="emit('update:show', $event)"
>
<FormField label="关联租户" hint="选中即关联,取消选中即解除;关联后租户全部 SDK 请求经此代理出站">
<TenantPicker
v-model:values="selected"
multiple
:configs="all"
:loading="configs.loading.value"
placeholder="选择要关联的租户"
/>
</FormField>
<div v-if="picked.length" class="mb-3 flex flex-wrap gap-1.5">
<span
v-for="c in picked"
:key="c.id"
class="inline-flex items-center gap-1 rounded-full border border-line bg-wash px-2.5 py-0.5 text-[11.5px] font-medium text-ink-2"
>
{{ c.alias }}
<button
type="button"
class="cursor-pointer text-ink-3 hover:text-err"
:aria-label="`移除 ${c.alias}`"
@click="removeOne(c.id)"
>
</button>
</span>
</div>
<div v-else class="mb-3 text-xs text-ink-3">暂无租户关联此代理</div>
<div
v-if="rebinding.length"
class="mb-3 flex items-start gap-2 rounded-lg border border-warn/30 bg-warn/10 px-3 py-2.5 text-xs leading-relaxed"
>
<svg
class="mt-0.5 h-[14px] w-[14px] flex-none text-warn"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z" />
<path d="M12 9v4M12 17h.01" />
</svg>
<span>
<template v-for="(c, i) in rebinding" :key="c.id">
<template v-if="i > 0"></template>
<b>{{ c.alias }}</b>
<span v-if="c.proxyName" class="text-ink-3">现挂 {{ c.proxyName }}</span>
</template>
已关联其他代理,保存后将改挂到本代理
</span>
</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="emit('update:show', false)">取消</NButton>
</div>
</NModal>
</template>
+135 -3
View File
@@ -1,12 +1,17 @@
<script setup lang="ts">
import { NButton, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { getAbout } from '@/api/settings'
import AppLogo from '@/components/AppLogo.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useParticles } from '@/composables/useParticles'
import { useAppStore } from '@/stores/app'
import type { AboutInfo } from '@/types/api'
// logo / ()
const app = useAppStore()
// GitHub ;
const REPO_URL = 'https://github.com/wangdefaa/oci-portal'
@@ -16,15 +21,56 @@ const loading = ref(true)
const pcanvas = ref<HTMLCanvasElement | null>(null)
useParticles(pcanvas, { count: 26 })
// :,30s
const loadedAt = ref(0)
const nowTick = ref(0)
let upTimer: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
try {
info.value = await getAbout()
loadedAt.value = nowTick.value = Date.now()
upTimer = setInterval(() => (nowTick.value = Date.now()), 30_000)
} catch {
/* 构建信息加载失败不阻塞页面,chips 显示占位 */
} finally {
loading.value = false
}
})
onUnmounted(() => {
if (upTimer) clearInterval(upTimer)
})
/** 运行时长分段:天(为 0 时省略)/ 小时 / 分 */
const uptimeParts = computed(() => {
const base = info.value?.uptimeSeconds
if (base === undefined) return []
const sec = base + Math.max(0, (nowTick.value - loadedAt.value) / 1000)
const d = Math.floor(sec / 86400)
const h = Math.floor((sec % 86400) / 3600)
const parts: { v: number; u: string }[] = []
if (d > 0) parts.push({ v: d, u: '天' })
if (d > 0 || h > 0) parts.push({ v: h, u: '小时' })
parts.push({ v: Math.floor((sec % 3600) / 60), u: '分' })
return parts
})
const DB_LABEL: Record<string, string> = { sqlite: 'SQLite', mysql: 'MySQL', postgres: 'PostgreSQL' }
/** 四格资源指标:值与单位拆开渲染,sub 为口径说明 */
const statCells = computed(() => {
const r = info.value?.resources
if (!r) return []
const heap = fmtBytes(r.memHeapBytes).split(' ')
const store = r.dbBytes >= 0 ? fmtBytes(r.dbBytes).split(' ') : ['—', '']
const db = DB_LABEL[r.dbEngine] ?? r.dbEngine
return [
{ label: 'CPU 均值', value: String(r.cpuAvgPercent), unit: '%', sub: `${r.numCpu} 核 · 占单核百分比` },
{ label: '内存(堆)', value: heap[0], unit: heap[1], sub: `向系统申请 ${fmtBytes(r.memSysBytes)}` },
{ label: '存储占用', value: store[0], unit: store[1], sub: r.dbBytes >= 0 ? `${db} 含 WAL 日志` : `${db} 外部数据库,不可度量` },
{ label: 'Goroutines', value: String(r.goroutines), unit: '', sub: '运行时并发任务数' },
]
})
function fmtBuild(t: string) {
return t ? t.replace('T', ' ').replace('Z', ' UTC') : '—'
@@ -45,7 +91,15 @@ function openRepo() {
<canvas ref="pcanvas" class="absolute inset-0 h-full w-full"></canvas>
</div>
<div class="relative">
<span class="floaty mx-auto block w-fit"><AppLogo :size="84" /></span>
<button
type="button"
class="floaty logo-btn mx-auto block w-fit"
title="切换 logo 款式"
aria-label="切换 logo 款式"
@click="app.toggleLogoVariant()"
>
<AppLogo :size="84" />
</button>
<div class="mt-5 font-serif text-[34px] leading-tight font-semibold">OCI Portal</div>
<div class="mt-1 text-[13px] text-ink-3">OCI 账号批量管理面板 · 自托管</div>
<div v-if="loading" class="mt-6 flex justify-center"><NSpin size="small" /></div>
@@ -57,8 +111,42 @@ function openRepo() {
</div>
</div>
<!-- 运行状态:运行时长随查看自增,资源指标为进程自启动累计口径 -->
<div v-if="info?.resources" class="panel">
<div
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-sm font-semibold">运行状态</span>
</div>
<span class="text-xs text-ink-3">
{{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
</span>
</div>
<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"
>{{ p.v }}<span class="uptime-unit">{{ p.u }}</span></template
>
</div>
</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-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
><span v-if="c.unit" class="ml-0.5 text-sm font-medium text-ink-2">{{ c.unit }}</span>
</div>
<div class="text-[11.5px] text-ink-3">{{ c.sub }}</div>
</div>
</div>
</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
@@ -76,6 +164,35 @@ function openRepo() {
</template>
<style scoped>
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-ok);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ok) 18%, transparent);
}
.uptime-unit {
margin: 0 8px 0 3px;
font-size: 16px;
font-weight: 500;
letter-spacing: 0;
color: var(--color-ink-2);
}
.uptime-unit:last-of-type {
margin-right: 0;
}
/* 四格分栏:桌面竖线分隔;窄屏 2 列换行时第二行改上边框接续 */
.statcell + .statcell {
border-left: 1px solid var(--color-line-soft);
}
@media (width < 768px) {
.statcell:nth-child(3) {
border-left: none;
}
.statcell:nth-child(n + 3) {
border-top: 1px solid var(--color-line-soft);
}
}
.chip {
display: inline-flex;
align-items: center;
@@ -98,6 +215,21 @@ function openRepo() {
border-radius: 50%;
border: 1.5px dashed color-mix(in srgb, var(--color-line) 70%, var(--color-accent) 30%);
}
/* logo 即款式开关:去按钮底色,悬停轻微放大提示可点 */
.logo-btn {
cursor: pointer;
border: none;
background: none;
padding: 0;
line-height: 0;
transition: scale 0.25s ease;
}
.logo-btn:hover {
scale: 1.06;
}
.logo-btn:active {
scale: 0.96;
}
@media (prefers-reduced-motion: no-preference) {
.spin {
animation: about-sp 52s linear infinite;
+84 -24
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch, useMessage } from 'naive-ui'
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
activateTotp,
disableTotp,
revokeSessions,
getCredentials,
getOAuthSettings,
getOauthAuthorizeUrl,
@@ -17,19 +18,29 @@ import {
updateOAuthSettings,
updatePasswordLogin,
} from '@/api/auth'
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 { 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 { TotpSetup, UpdateOAuthRequest } from '@/types/api'
import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
import { useToast } from '@/composables/useToast'
const message = useMessage()
const message = useToast()
/** TOTP 启用引导弹窗的移动端全屏形态 */
const { modalStyle, modalClass } = useMobileModal(360)
const router = useRouter()
const auth = useAuthStore()
const app = useAppStore()
/** 敏感操作响应携带新会话时无感替换;204 降级(undefined)交由 401 流程重登 */
function applySession(r: SessionRefresh) {
if (r?.token) auth.setSession(r.token, r.expiresAt)
}
const providerLabels: Record<string, string> = { github: 'GitHub', oidc: 'OIDC 单点登录' }
@@ -69,7 +80,7 @@ async function confirmTotpActivate() {
}
totpBusy.value = true
try {
await activateTotp(totpActivateCode.value)
applySession(await activateTotp(totpActivateCode.value))
message.success('两步验证已启用,下次登录需输入动态验证码')
showTotpSetup.value = false
totpPending.value = null
@@ -86,7 +97,7 @@ async function confirmTotpDisable() {
totpBusy.value = true
try {
// 6 ,
await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input })
applySession(await disableTotp(/^\d{6}$/.test(input) ? { code: input } : { password: input }))
message.success('两步验证已停用')
totpDisableInput.value = ''
showTotpDisable.value = false
@@ -151,15 +162,22 @@ async function saveCredentials() {
const { nameChanged, pwChanged } = credChanges.value
savingCred.value = true
try {
await updateCredentials({
const refresh = await updateCredentials({
...(nameChanged ? { newUsername: credForm.newUsername.trim() } : {}),
...(pwChanged ? { newPassword: credForm.newPassword } : {}),
currentPassword: credForm.currentPassword,
})
message.success('登录凭据已更新,请使用新凭据重新登录')
showCredModal.value = false
auth.logout()
void router.push('/login')
if (refresh?.token) {
// 使; token
applySession(refresh)
message.success('登录凭据已更新,其他已登录会话已全部失效')
void creds.run({ silent: true })
} else {
message.success('登录凭据已更新,请使用新凭据重新登录')
auth.logout()
void router.push('/login')
}
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
@@ -171,7 +189,7 @@ async function saveCredentials() {
async function togglePasswordLogin(disabled: boolean) {
togglingPwLogin.value = true
try {
await updatePasswordLogin(disabled)
applySession(await updatePasswordLogin(disabled))
message.success(disabled ? '已禁用密码登录,仅外部身份可登录' : '已恢复密码登录')
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
@@ -191,6 +209,11 @@ const bindableProviders = computed(() => {
return out
})
/** 已绑定的 provider 集合;绑定入口按钮据此禁用,解绑后自动恢复 */
const boundProviders = computed(
() => new Set((identities.data.value?.items ?? []).map((it) => it.provider)),
)
async function bindProvider(provider: string) {
binding.value = provider
try {
@@ -204,7 +227,7 @@ async function bindProvider(provider: string) {
async function removeIdentity(id: number) {
try {
await unbindIdentity(id)
applySession(await unbindIdentity(id))
message.success('已解绑;该身份后续无法登录面板')
void identities.run()
} catch (e) {
@@ -212,6 +235,21 @@ 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'
@@ -391,7 +429,7 @@ async function deleteProvider(p: ProviderType) {
<div class="min-w-0">
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
<div class="mt-0.5 text-xs text-ink-3">
登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password
登录需额外输入验证器动态码
</div>
</div>
<NSpin v-if="totp.loading.value" size="small" />
@@ -419,6 +457,18 @@ async function deleteProvider(p: ProviderType) {
<NButton size="tiny" quaternary @click="openCredModal">修改</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">撤销全部会话</div>
<div class="mt-0.5 text-xs text-ink-3">
怀疑令牌泄露时使用;其他设备与旧令牌立即失效,本会话自动换新
</div>
</div>
<NButton size="tiny" type="error" quaternary :loading="revoking" @click="revokeAllSessions">
撤销
</NButton>
</div>
<div class="flex items-center justify-between gap-3 border-b border-line-soft py-2.5">
<div class="min-w-0">
<div class="text-[13px] font-medium">禁用密码登录</div>
@@ -450,12 +500,17 @@ async function deleteProvider(p: ProviderType) {
</div>
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
</div>
<NPopconfirm @positive-click="removeIdentity(it.id)">
<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
@@ -467,9 +522,10 @@ async function deleteProvider(p: ProviderType) {
:key="p"
size="small"
:loading="binding === p"
:disabled="boundProviders.has(p)"
@click="bindProvider(p)"
>
绑定 {{ displayNameOf(p) }}
{{ boundProviders.has(p) ? '已绑定' : '绑定' }} {{ displayNameOf(p) }}
</NButton>
<span class="text-xs text-ink-3">将跳转到对应平台授权</span>
</div>
@@ -529,12 +585,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>
@@ -544,9 +604,8 @@ async function deleteProvider(p: ProviderType) {
尚未配置任何登录方式;点击右上新增登录方式配置 GitHub OIDC
</div>
<FootNote>
IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
}}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback;secret ·
需先在网络与地址保存面板地址
IdP / GitHub 应用中填写的授权回调地址:
{{ callbackBase}}/api/v1/auth/oauth/&lt;github|oidc&gt;/callback
</FootNote>
</div>
@@ -690,7 +749,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">
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
@@ -699,7 +759,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"
/>
+263
View File
@@ -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>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import { NButton, NCollapseTransition, NInput, NSwitch } from 'naive-ui'
import { computed, reactive, ref, watch } from 'vue'
import { testNotifyChannel, updateNotifyChannel } from '@/api/settings'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import { useToast } from '@/composables/useToast'
import type { NotifyChannelItem, NotifyChannelType } from '@/types/api'
const props = defineProps<{ item: NotifyChannelItem }>()
const emit = defineEmits<{ saved: [NotifyChannelItem[]] }>()
const message = useToast()
/** 渠道展示元数据:图标字符 + 名称 + 一句话说明 + 密文字段文案 */
const CHANNEL_META: Record<
NotifyChannelType,
{ icon: string; name: string; hint: string; secretLabel?: string; secretHint?: string }
> = {
webhook: {
icon: 'W',
name: 'Webhook',
hint: '通用 HTTP POST,可对接飞书 / 钉钉 / Slack / 企业微信机器人',
},
ntfy: {
icon: 'N',
name: 'ntfy',
hint: '开源推送服务,官方 ntfy.sh 或自建服务端',
secretLabel: 'Access Token',
secretHint: '服务端开启鉴权时填写;留空表示沿用已保存值',
},
bark: {
icon: 'B',
name: 'Bark',
hint: 'iOS 推送,官方 api.day.app 或自建服务端',
secretLabel: 'Device Key',
secretHint: 'Bark App 内的设备 key;留空表示沿用已保存值',
},
smtp: {
icon: 'M',
name: 'SMTP 邮件',
hint: '465 端口走隐式 TLS,其余端口自动 STARTTLS',
secretLabel: '密码',
secretHint: 'SMTP 授权码或密码;留空表示沿用已保存值',
},
}
/** Webhook body 模板预设(占位符 {{title}} / {{text}} 发送时替换并做 JSON 转义) */
const WEBHOOK_PRESETS = [
{ label: 'Slack', tpl: '{"text": "{{title}}\\n{{text}}"}' },
{ label: '飞书', tpl: '{"msg_type": "text", "content": {"text": "{{title}}\\n{{text}}"}}' },
{ label: '钉钉', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
{ label: '企业微信', tpl: '{"msgtype": "text", "text": {"content": "{{title}}\\n{{text}}"}}' },
]
const meta = computed(() => CHANNEL_META[props.item.type])
const expanded = ref(false)
const saving = ref(false)
const testing = ref(false)
const form = reactive({
enabled: false,
url: '',
bodyTemplate: '',
server: '',
topic: '',
host: '',
port: 587 as number | null,
username: '',
from: '',
to: '',
secret: '',
})
// /;,沿
watch(
() => props.item,
(it) => {
form.enabled = it.enabled
form.url = it.url ?? ''
form.bodyTemplate = it.bodyTemplate ?? ''
form.server = it.server ?? ''
form.topic = it.topic ?? ''
form.host = it.host ?? ''
form.port = it.port || 587
form.username = it.username ?? ''
form.from = it.from ?? ''
form.to = it.to ?? ''
form.secret = ''
},
{ immediate: true },
)
const secretPlaceholder = computed(() =>
props.item.secretSet
? `已配置${props.item.secretTail ? `(尾号 ${props.item.secretTail}` : ''},留空沿用`
: '',
)
async function save() {
saving.value = true
try {
const secret = form.secret.trim()
const items = await updateNotifyChannel(props.item.type, {
enabled: form.enabled,
url: form.url.trim(),
bodyTemplate: form.bodyTemplate,
server: form.server.trim(),
topic: form.topic.trim(),
host: form.host.trim(),
port: form.port ?? 0,
username: form.username.trim(),
from: form.from.trim(),
to: form.to.trim(),
// 沿
...(secret ? { secret } : {}),
})
message.success(`${meta.value.name} 配置已保存`)
form.secret = ''
emit('saved', items)
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
async function sendTest() {
testing.value = true
try {
await testNotifyChannel(props.item.type)
message.success(`测试消息已发送,请在 ${meta.value.name} 侧查收`)
} catch (e) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
testing.value = false
}
}
</script>
<template>
<div class="border-b border-line-soft last:border-b-0">
<button
type="button"
class="flex w-full cursor-pointer items-center gap-3 px-4.5 py-3.5 text-left"
@click="expanded = !expanded"
>
<span
class="flex h-8.5 w-8.5 flex-none items-center justify-center rounded-full bg-wash text-[15px] font-bold text-ink-2"
>
{{ meta.icon }}
</span>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-[13px] font-medium">{{ meta.name }}</span>
<span
class="inline-flex items-center gap-1.5 rounded-full bg-wash px-2 py-px text-[11.5px] text-ink-2"
>
<span
class="h-1.5 w-1.5 rounded-full"
:class="item.enabled ? 'bg-ok' : 'bg-ink-3/50'"
/>
{{ item.enabled ? '已启用' : '未启用' }}
</span>
</div>
<div class="mt-0.5 text-xs text-ink-3">{{ meta.hint }}</div>
</div>
<span class="text-xs text-ink-3">{{ expanded ? '收起' : '配置' }}</span>
</button>
<NCollapseTransition :show="expanded">
<div class="px-4.5 pb-3.5">
<div class="mb-3 flex items-center justify-between rounded-lg bg-wash px-3 py-2">
<span class="text-[12.5px]">启用该渠道</span>
<NSwitch v-model:value="form.enabled" size="small" />
</div>
<!-- Webhook -->
<template v-if="item.type === 'webhook'">
<FormField label="Webhook URL" hint="机器人/集成的完整回调地址,含鉴权参数">
<NInput v-model:value="form.url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/…" />
</FormField>
<FormField
label="Body 模板"
hint="发送时 {{title}} / {{text}} 按 JSON 字符串转义后替换;留空按 Slack 形状发送"
>
<div class="mb-1.5 flex flex-wrap gap-1.5">
<NButton
v-for="p in WEBHOOK_PRESETS"
:key="p.label"
size="tiny"
quaternary
type="primary"
@click="form.bodyTemplate = p.tpl"
>
{{ p.label }}
</NButton>
</div>
<NInput
v-model:value="form.bodyTemplate"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
class="mono"
:placeholder="WEBHOOK_PRESETS[0].tpl"
/>
</FormField>
</template>
<!-- ntfy -->
<template v-else-if="item.type === 'ntfy'">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="服务端" hint="留空用官方 ntfy.sh">
<NInput v-model:value="form.server" placeholder="https://ntfy.sh" />
</FormField>
<FormField label="Topic" hint="订阅的主题名">
<NInput v-model:value="form.topic" placeholder="oci-portal" />
</FormField>
</div>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'tk_…(可选)'"
/>
</FormField>
</template>
<!-- Bark -->
<template v-else-if="item.type === 'bark'">
<FormField label="服务端" hint="留空用官方 api.day.app">
<NInput v-model:value="form.server" placeholder="https://api.day.app" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'Bark App 内复制'"
/>
</FormField>
</template>
<!-- SMTP -->
<template v-else>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="主机">
<NInput v-model:value="form.host" placeholder="smtp.example.com" />
</FormField>
<FormField label="端口" hint="465 隐式 TLS,587 STARTTLS">
<AppInputNumber v-model:value="form.port" :min="1" :max="65535" class="!w-full" />
</FormField>
<FormField label="用户名" hint="留空表示免认证">
<NInput v-model:value="form.username" placeholder="user@example.com" />
</FormField>
<FormField :label="meta.secretLabel!" :hint="meta.secretHint">
<NInput
v-model:value="form.secret"
type="password"
show-password-on="click"
:placeholder="secretPlaceholder || 'SMTP 授权码'"
/>
</FormField>
<FormField label="发件人">
<NInput v-model:value="form.from" placeholder="noreply@example.com" />
</FormField>
<FormField label="收件人">
<NInput v-model:value="form.to" placeholder="me@example.com" />
</FormField>
</div>
</template>
<div class="mt-1 flex items-center gap-2">
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
<NButton size="small" :loading="testing" @click="sendTest">发送测试消息</NButton>
<span class="text-xs text-ink-3">测试使用已保存的配置,修改后请先保存</span>
</div>
</div>
</NCollapseTransition>
</div>
</template>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { NButton, NInput, NModal, useMessage } from 'naive-ui'
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'
const props = defineProps<{
show: boolean
@@ -18,7 +20,8 @@ const emit = defineEmits<{
saved: [kind: string, template: string]
}>()
const message = useMessage()
const message = useToast()
const { modalStyle, modalClass } = useMobileModal(600)
const draft = ref('')
const saving = ref(false)
const testing = ref(false)
@@ -103,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">

Some files were not shown because too many files have changed in this diff Show More