From ac457282ce027806dbb392d173e511066abc4896 Mon Sep 17 00:00:00 2001 From: Wang Defa <1+wangdefa@noreply.gitea.bcde.io> Date: Tue, 21 Jul 2026 12:11:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=B9=E8=B1=A1=E5=AD=98?= =?UTF-8?q?=E5=82=A8/=E8=B4=A6=E5=8D=95/=E4=BF=9D=E7=95=99IP=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2,=E6=88=90=E6=9C=AC=E4=BB=8A=E5=A4=A9=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E6=A0=B7=E5=BC=8F=E6=94=B6=E6=95=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trellis/spec/frontend/api-and-state.md | 6 + .../spec/frontend/component-guidelines.md | 59 -- .trellis/spec/frontend/directory-structure.md | 2 +- .trellis/spec/frontend/hook-guidelines.md | 51 -- .trellis/spec/frontend/quality-guidelines.md | 51 -- .trellis/spec/frontend/state-management.md | 51 -- .../spec/frontend/styling-design-tokens.md | 17 +- .trellis/spec/frontend/type-safety.md | 51 -- .trellis/spec/frontend/typescript-vue.md | 5 + .trellis/spec/guides/design-workflow.md | 2 +- .trellis/spec/guides/index.md | 1 + package-lock.json | 583 +++++++++++++++- package.json | 24 +- public/mock-samples/sample.docx | Bin 0 -> 1467 bytes public/mock-samples/sample.pdf | 11 + public/mock-samples/sample.xlsx | Bin 0 -> 2103 bytes scripts/gen-file-icons.mjs | 55 ++ src/api/aigateway.ts | 4 +- src/api/analytics.ts | 4 +- src/api/billing.ts | 51 ++ src/api/instances.ts | 43 +- src/api/mock.ts | 210 ++++++ src/api/networks.ts | 21 +- src/api/objectstorage.ts | 361 ++++++++++ src/api/request.ts | 43 +- src/api/reservedips.ts | 75 ++ src/assets/main.css | 45 ++ src/components/CompartmentSelect.vue | 83 +++ src/components/ConfirmPop.vue | 144 ++++ src/components/PanelHeader.vue | 25 + src/components/TenantPicker.vue | 9 +- src/components/ai/AiChannelPanel.vue | 14 +- src/components/ai/AiKeyPanel.vue | 14 +- src/components/instance/AttachVnicModal.vue | 46 +- src/components/instance/ChangeIpModal.vue | 116 +++ .../instance/CreateInstanceModal.vue | 7 +- src/components/instance/InstanceSpecForm.vue | 55 +- src/components/instance/VnicPanel.vue | 89 ++- src/components/logs/AiCallLogPanel.vue | 9 +- src/components/logs/LogEventPanel.vue | 16 +- src/components/logs/SystemLogPanel.vue | 9 +- src/components/network/ReservedIpPanel.vue | 247 +++++++ src/components/network/SecurityRuleEditor.vue | 295 ++++++++ src/components/network/reservedIpSelect.ts | 26 + src/components/objectstorage/FileIcon.vue | 58 ++ .../objectstorage/ObjectBrowser.vue | 660 ++++++++++++++++++ .../objectstorage/ObjectViewerModal.vue | 517 ++++++++++++++ src/components/objectstorage/ParPanel.vue | 206 ++++++ src/components/objectstorage/codeHighlight.ts | 94 +++ src/components/objectstorage/fileIcons.ts | 60 ++ src/components/objectstorage/format.ts | 61 ++ .../objectstorage/viewer/OfficePreview.vue | 39 ++ .../objectstorage/viewer/TextEditPane.vue | 179 +++++ .../objectstorage/viewer/TextPreviewPane.vue | 184 +++++ src/components/proxy/ProxyPanel.vue | 7 +- src/components/settings/AboutTab.vue | 12 +- .../settings/AccountSecurityCard.vue | 27 +- src/components/tenant/AuditLogTab.vue | 10 +- src/components/tenant/CostTab.vue | 182 +++-- src/components/tenant/IdpTab.vue | 11 +- src/components/tenant/InvoiceDetailModal.vue | 129 ++++ src/components/tenant/InvoicePayModal.vue | 100 +++ src/components/tenant/InvoicePdfModal.vue | 77 ++ src/components/tenant/InvoiceTab.vue | 348 +++++++++ src/components/tenant/MiscTab.vue | 32 +- src/components/tenant/PaymentMethodsModal.vue | 86 +++ src/components/tenant/QuotaTab.vue | 20 +- src/components/tenant/SubsTab.vue | 8 +- src/components/tenant/UsersTab.vue | 20 +- src/components/tenant/invoiceStatus.ts | 18 + src/composables/useFormat.ts | 8 + src/layouts/AppLayout.vue | 1 + src/layouts/SidebarNav.vue | 8 + src/router/index.ts | 5 + src/stores/scope.ts | 7 +- src/theme/naive.ts | 11 + src/types/api.ts | 113 +++ src/views/AiGatewayView.vue | 11 +- src/views/BootVolumeListView.vue | 20 +- src/views/InstanceDetailView.vue | 66 +- src/views/InstanceListView.vue | 42 +- src/views/LoginView.vue | 2 +- src/views/NetworkListView.vue | 48 +- src/views/ObjectStorageView.vue | 370 ++++++++++ src/views/SettingsView.vue | 108 ++- src/views/TaskDetailView.vue | 22 +- src/views/TenantDetailView.vue | 34 +- src/views/TenantListView.vue | 41 +- src/views/VcnDetailView.vue | 198 +++++- src/views/VncConsoleView.vue | 32 +- src/views/errors/BlockedView.vue | 2 +- 91 files changed, 6672 insertions(+), 652 deletions(-) delete mode 100644 .trellis/spec/frontend/component-guidelines.md delete mode 100644 .trellis/spec/frontend/hook-guidelines.md delete mode 100644 .trellis/spec/frontend/quality-guidelines.md delete mode 100644 .trellis/spec/frontend/state-management.md delete mode 100644 .trellis/spec/frontend/type-safety.md create mode 100644 public/mock-samples/sample.docx create mode 100644 public/mock-samples/sample.pdf create mode 100644 public/mock-samples/sample.xlsx create mode 100644 scripts/gen-file-icons.mjs create mode 100644 src/api/billing.ts create mode 100644 src/api/objectstorage.ts create mode 100644 src/api/reservedips.ts create mode 100644 src/components/CompartmentSelect.vue create mode 100644 src/components/ConfirmPop.vue create mode 100644 src/components/PanelHeader.vue create mode 100644 src/components/instance/ChangeIpModal.vue create mode 100644 src/components/network/ReservedIpPanel.vue create mode 100644 src/components/network/SecurityRuleEditor.vue create mode 100644 src/components/network/reservedIpSelect.ts create mode 100644 src/components/objectstorage/FileIcon.vue create mode 100644 src/components/objectstorage/ObjectBrowser.vue create mode 100644 src/components/objectstorage/ObjectViewerModal.vue create mode 100644 src/components/objectstorage/ParPanel.vue create mode 100644 src/components/objectstorage/codeHighlight.ts create mode 100644 src/components/objectstorage/fileIcons.ts create mode 100644 src/components/objectstorage/format.ts create mode 100644 src/components/objectstorage/viewer/OfficePreview.vue create mode 100644 src/components/objectstorage/viewer/TextEditPane.vue create mode 100644 src/components/objectstorage/viewer/TextPreviewPane.vue create mode 100644 src/components/tenant/InvoiceDetailModal.vue create mode 100644 src/components/tenant/InvoicePayModal.vue create mode 100644 src/components/tenant/InvoicePdfModal.vue create mode 100644 src/components/tenant/InvoiceTab.vue create mode 100644 src/components/tenant/PaymentMethodsModal.vue create mode 100644 src/components/tenant/invoiceStatus.ts create mode 100644 src/views/ObjectStorageView.vue diff --git a/.trellis/spec/frontend/api-and-state.md b/.trellis/spec/frontend/api-and-state.md index 50a090d..a77eb1c 100644 --- a/.trellis/spec/frontend/api-and-state.md +++ b/.trellis/spec/frontend/api-and-state.md @@ -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`),错误向上抛由封装层兜底,不在每个调用点重复 try/catch。 +- 消息提示统一走 `useToast`(`src/composables/useToast`),禁止组件直接 `useMessage`:超长错误(如 OCI 透传)自动折叠成「标题 + 展开详情」,显式传 detail 可自定义折叠内容。 +- 账单域(round14 起):发票/付款方式 DTO 在 `types/api.ts`,请求走 `api/billing.ts`;发票 PDF 接口带 JWT 不能裸 ``,统一经 `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 联动父级刷新)。 diff --git a/.trellis/spec/frontend/component-guidelines.md b/.trellis/spec/frontend/component-guidelines.md deleted file mode 100644 index 6836c3f..0000000 --- a/.trellis/spec/frontend/component-guidelines.md +++ /dev/null @@ -1,59 +0,0 @@ -# Component Guidelines - -> How components are built in this project. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## Component Structure - - - -(To be filled by the team) - ---- - -## Props Conventions - - - -(To be filled by the team) - ---- - -## Styling Patterns - - - -(To be filled by the team) - ---- - -## Accessibility - - - -(To be filled by the team) - ---- - -## Common Mistakes - - - -(To be filled by the team) diff --git a/.trellis/spec/frontend/directory-structure.md b/.trellis/spec/frontend/directory-structure.md index 40761cc..276b7f3 100644 --- a/.trellis/spec/frontend/directory-structure.md +++ b/.trellis/spec/frontend/directory-structure.md @@ -1,6 +1,6 @@ # 前端目录结构 -> Vue 3 前端工程的目录职责与命名约定。 +> Vue 3 前端工程(`oci-portal-dash/`)的目录职责与命名约定。 ## 目录职责 diff --git a/.trellis/spec/frontend/hook-guidelines.md b/.trellis/spec/frontend/hook-guidelines.md deleted file mode 100644 index 60c6bb6..0000000 --- a/.trellis/spec/frontend/hook-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Hook Guidelines - -> How hooks are used in this project. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## Custom Hook Patterns - - - -(To be filled by the team) - ---- - -## Data Fetching - - - -(To be filled by the team) - ---- - -## Naming Conventions - - - -(To be filled by the team) - ---- - -## Common Mistakes - - - -(To be filled by the team) diff --git a/.trellis/spec/frontend/quality-guidelines.md b/.trellis/spec/frontend/quality-guidelines.md deleted file mode 100644 index 05a1411..0000000 --- a/.trellis/spec/frontend/quality-guidelines.md +++ /dev/null @@ -1,51 +0,0 @@ -# Quality Guidelines - -> Code quality standards for frontend development. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## Forbidden Patterns - - - -(To be filled by the team) - ---- - -## Required Patterns - - - -(To be filled by the team) - ---- - -## Testing Requirements - - - -(To be filled by the team) - ---- - -## Code Review Checklist - - - -(To be filled by the team) diff --git a/.trellis/spec/frontend/state-management.md b/.trellis/spec/frontend/state-management.md deleted file mode 100644 index b4fc966..0000000 --- a/.trellis/spec/frontend/state-management.md +++ /dev/null @@ -1,51 +0,0 @@ -# State Management - -> How state is managed in this project. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## State Categories - - - -(To be filled by the team) - ---- - -## When to Use Global State - - - -(To be filled by the team) - ---- - -## Server State - - - -(To be filled by the team) - ---- - -## Common Mistakes - - - -(To be filled by the team) diff --git a/.trellis/spec/frontend/styling-design-tokens.md b/.trellis/spec/frontend/styling-design-tokens.md index 48c49cc..a989c37 100644 --- a/.trellis/spec/frontend/styling-design-tokens.md +++ b/.trellis/spec/frontend/styling-design-tokens.md @@ -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**:`` 弹窗实际渲染为全宽。 @@ -106,6 +113,14 @@ 验证方法:build 后 `grep -o 'html\.dark[^}]*}' dist/assets/-*.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 浅底」因用户不接受色块框已废弃。) diff --git a/.trellis/spec/frontend/type-safety.md b/.trellis/spec/frontend/type-safety.md deleted file mode 100644 index 1b1b19e..0000000 --- a/.trellis/spec/frontend/type-safety.md +++ /dev/null @@ -1,51 +0,0 @@ -# Type Safety - -> Type safety patterns in this project. - ---- - -## Overview - - - -(To be filled by the team) - ---- - -## Type Organization - - - -(To be filled by the team) - ---- - -## Validation - - - -(To be filled by the team) - ---- - -## Common Patterns - - - -(To be filled by the team) - ---- - -## Forbidden Patterns - - - -(To be filled by the team) diff --git a/.trellis/spec/frontend/typescript-vue.md b/.trellis/spec/frontend/typescript-vue.md index 52f3303..1772dc1 100644 --- a/.trellis/spec/frontend/typescript-vue.md +++ b/.trellis/spec/frontend/typescript-vue.md @@ -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)。 diff --git a/.trellis/spec/guides/design-workflow.md b/.trellis/spec/guides/design-workflow.md index e0b2ae8..c1b01d7 100644 --- a/.trellis/spec/guides/design-workflow.md +++ b/.trellis/spec/guides/design-workflow.md @@ -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 一一对应。 diff --git a/.trellis/spec/guides/index.md b/.trellis/spec/guides/index.md index 56c6d77..28c5dca 100644 --- a/.trellis/spec/guides/index.md +++ b/.trellis/spec/guides/index.md @@ -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 设计稿 / 评审视觉方案时 | --- diff --git a/package-lock.json b/package-lock.json index a8d145f..73ffe95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,38 @@ { "name": "oci-portal-dash", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "oci-portal-dash", - "version": "0.1.0", + "version": "0.2.0", "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", @@ -19,6 +40,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", @@ -28,7 +50,7 @@ "@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", @@ -81,6 +103,235 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.1.tgz", + "integrity": "sha512-6re5avCNfyRMIoi3XNjbEfQM1vTeVD3JS3g/Fyegyso/eoANFM71Cyvbb66LDyYtQLMEcRFlzioywCqDo9SlLA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz", + "integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@css-render/plugin-bem": { "version": "0.15.14", "resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz", @@ -806,6 +1057,23 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-json/catppuccin": { + "version": "1.2.17", + "resolved": "https://registry.npmjs.org/@iconify-json/catppuccin/-/catppuccin-1.2.17.tgz", + "integrity": "sha512-l1pFOADEUlw+j7V+yfFzBowdE4deeMuI4amqfKaN+7uTI9B0Ho24C86DitQk5zb62NkEM9poOyfuG7WS2bw6jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -861,6 +1129,134 @@ "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==", "license": "Apache-2.0" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/css": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.4.tgz", + "integrity": "sha512-N+tn9tej2hPvyKgHEApMOQfHczDJCwxrRFS3SPn9QjYN+uwHvEDnCgKRrb3mxDYxRS8sKMM8fhC3+lc04Abz5Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", + "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.19.tgz", + "integrity": "sha512-MhQIURHRytsNzP/YXnqpYKW6la6voAH3kyplTOOiCdjyFY6cWWGFVmYVdHIPrElqSDf4iCDktQCockB9FxuhzQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -918,6 +1314,18 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@prettier/plugin-xml": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-3.4.2.tgz", + "integrity": "sha512-/UyNlHfkuLXG6Ed85KB0WBF283xn2yavR+UtRibBRUcvEJId2DSLdGXwJ/cDa1X++SWDPzq3+GSFniHjkNy7yg==", + "license": "MIT", + "dependencies": { + "@xml-tools/parser": "^1.0.11" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -1637,6 +2045,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.62.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.1.tgz", @@ -1923,6 +2338,74 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vue-office/docx": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@vue-office/docx/-/docx-1.6.3.tgz", + "integrity": "sha512-Cs+3CAaRBOWOiW4XAhTwwxJ0dy8cPIf6DqfNvYcD3YACiLwO4kuawLF2IAXxyijhbuOeoFsfvoVbOc16A/4bZA==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "@vue/composition-api": "^1.7.1", + "vue": "^2.0.0 || >=3.0.0", + "vue-demi": "^0.14.6" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-office/excel": { + "version": "1.7.14", + "resolved": "https://registry.npmjs.org/@vue-office/excel/-/excel-1.7.14.tgz", + "integrity": "sha512-pVUgt+emDQUnW7q22CfnQ+jl43mM/7IFwYzOg7lwOwPEbiVB4K4qEQf+y/bc4xGXz75w1/e3Kz3G6wAafmFBFg==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "@vue/composition-api": "^1.7.1", + "vue": "^2.0.0 || >=3.0.0", + "vue-demi": "^0.14.6" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-office/pdf": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@vue-office/pdf/-/pdf-2.0.10.tgz", + "integrity": "sha512-yHVLrMAKpMPBkhBwofFyGEtEeJF0Zd7oGmf56Pe5aj/xObdRq3E1CIZqTqhWJNgHV8oLQqaX0vs4p5T1zq+GIA==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "@vue/composition-api": "^1.7.1", + "vue": "^2.0.0 || >=3.0.0", + "vue-demi": "^0.14.6" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-office/pptx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@vue-office/pptx/-/pptx-1.0.1.tgz", + "integrity": "sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==", + "hasInstallScript": true, + "license": "MIT", + "peerDependencies": { + "@vue/composition-api": "^1.7.1", + "vue": "^2.0.0 || >=3.0.0", + "vue-demi": "^0.14.6" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.39", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz", @@ -2161,6 +2644,15 @@ } } }, + "node_modules/@xml-tools/parser": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@xml-tools/parser/-/parser-1.0.11.tgz", + "integrity": "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==", + "license": "Apache-2.0", + "dependencies": { + "chevrotain": "7.1.1" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", @@ -2322,6 +2814,30 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chevrotain": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-7.1.1.tgz", + "integrity": "sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==", + "license": "Apache-2.0", + "dependencies": { + "regexp-to-ast": "0.5.0" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2349,6 +2865,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2460,6 +2982,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.12", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", + "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/echarts": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", @@ -3601,6 +4132,27 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.6.tgz", + "integrity": "sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz", + "integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3909,10 +4461,9 @@ } }, "node_modules/prettier": { - "version": "3.9.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", - "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", - "dev": true, + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -3968,6 +4519,12 @@ ], "license": "MIT" }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -4122,6 +4679,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4627,6 +5190,12 @@ "vue": "^3.0.11" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 563e8a6..e85ea87 100644 --- a/package.json +++ b/package.json @@ -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,7 +54,7 @@ "@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", diff --git a/public/mock-samples/sample.docx b/public/mock-samples/sample.docx new file mode 100644 index 0000000000000000000000000000000000000000..3d3eee5ac66221b816e5da058c1731fcf27925ae GIT binary patch literal 1467 zcmWIWW@Zs#U|`^2;Ggv+=1lYDjcWn> zq#v^(&))CaO7Rzc*!VaW`c)@bGPQqV40|-Q^TaX>-M9B2XD(z9vAC7L`F_z}{h`teztQb^HI^ouJe_?7AUlR&DwZJ#%QBZ;S;%KQJ?o-%TePx zk$XynRWgV(B2{PcxeU7kmDzDpIWvkQ z_-AY`XIuTCO8o8m8PBX2i9Vfpc=G8QRhA3WmD66lZ;^kvdP?vd)vs+!_az9-`sZ^r zYxWtljxB%hKR>iIQ)14-&2?p`RV9TFkkbcdEMIK z9@|5=E`E5qLDZ}2q{5d?Y(_eA{}0}^$ZB*~&tuE|wK4hX#8f#EH8y^6m8tJbW=1af zDf8=VO7tEE+@5CzdaOLZC$ueR@dw;ky36HE*Yf-og1`N^fZz!U^ZI=w+>{cbx5 z?E5UT+O|<-52we9qGV;g749rg8GH6B3VzFbQYv(aQzO8mfunKNvPb3}@{Ed2+zWFb zB%Y{xyM2Z5?gLA`^gl2D`D}K++hm!!+kGY+H#;4$=s=4|JA?3CDM_Q%2VS0k+$^t< z(C5Z;b_t92h7L)Y(BG3-Z!V}hcjyQsBXeSrV`Jv=lczWgZF_ZlJx#hj1H1!oUz($^ zH9fHLmfN*uR%tpC2{SV^wd=yHkQq3^h(vo(*}RzNYku zszT7@7ox(m4CXn?o)-I8#?$X|W5v4_d+JWr&)dD!cwg`<{%aD;Lgq6k?LL?tf23dZ!qX4g+V_rDoe&YOE?1a;xY=atsTUHh&a=M1 zyZG+(qoVbO@5L(hZ(GL;o$fEnpU9~xbohGM&$o+zgns1DdUvpRcjWgd^NnT2-dE;V zq_X?;_}0DgHCJ)HRQdJN-8&z?i&wt9k-ar(QH}Dt(+>X6Pu!{hz~k-kM*idN!ygLb z9y`8?Wm_2^Te`ot)Zg##YTxx6Rm`sae1FRRTua=1mc4ry-1^RrJ0XBlI5;7|vH~NM z95W&_pqC~<)eOM&!LXze#KKax1bBlCz@6hj+87uF7~VR{0?om0DS8eB>3~@32-gG7 zkN7MDrEmxUStbQc(9rab>Ou4r57Gj$%NC{uIsN0a5fqdV0J2e;1ubA%K?M#28xTeU LE${(KGcW)E_GTGU literal 0 HcmV?d00001 diff --git a/public/mock-samples/sample.pdf b/public/mock-samples/sample.pdf new file mode 100644 index 0000000..c88e2ef --- /dev/null +++ b/public/mock-samples/sample.pdf @@ -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 diff --git a/public/mock-samples/sample.xlsx b/public/mock-samples/sample.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..aed17eacbae5c6f6446a3e96d61e87542db26acf GIT binary patch literal 2103 zcmWIWW@Zs#U|`^2;Ggv+#_2)o&;LN42@?Z@Fp!RR&d)1J%`1rysVqn>j@7Hk%~>00 z?SI%n;NEX-o%Nw_rZ6gA*}~i}C~=D4;*EFGQT?BHCjG0PBVx6nNn(ay`TgK;Rn}Ig z`DUD8x&LA63QOnpO=kPDf4qLTao58hqY7TLiku&Uo%!b7U*Abgj1a8Os+eEAXX>Po zem;+uj(rNb9(+ZS!RpE@7_Fv+m)7crevqtP_vy*O?whvTHf*11+w-(8YtjXw^m6rQ zBG(E!E=sZPeDLYP*X=W289RwQJ+tthOOzPX^y$iKFP^vPKP;}ClVY+-+~X%_%)*lw zPko=zwq1bj}zSC)&AShJeY}j+>E&{tplQcq-3zCTG&;h`+^?H~-(+ zG3S5H<{u0J!0_S%h71EZyub`7!3Lz`i&Arn^+9S7VS0Sl`_@fBIc-J;25z_-J&?-Y z^Jn>*40zZ-So?4MqQ}gCOoB~3`Owt|%x41a4n<7Q)!MVyTI=J3jhX-VZ*$#xW|r;6 zpA&l;Y|b_;47}2}OuQglZ@036{F2P2YRybxUjn9j7P{&>pZSrvWuEJi3Pxv+Z%1~9 zGoQP(JmumMKli+8iqqCT4_oE=?Zo%UvIp-jH}IZ%71pr&woJt4s6Xt>*GY@c^R!Jf zd3e0vzO~r}g)oq$l&{>aaQmPRbRy^sS&l@o|pZ?2XFRWsUQ! zFBCJZYu&-x=HUO-e(&?|TX!$F*=^jVwv2DvY$=&b2MjMRZ2rCbc6W9`6w^@y<3#rJ zD>l4d{cYmY9|vkmmZ`r@&`QnRb3O5pOvz#HNwIFzWV(8bKJ4OXTOpiua`XN*ueIkp zD@Djk{p@G&PnP=7)*kGpwYu|xOxM!unz~$KZ=bh*%Ptk!bo5_M_0NiW(<$%o+DU0z zWKJ^O)F1t!_H}(~=n3*$2#X5*TX1S`0yf zRXXXc*I@^d*6+1l2HLSRPOf*E>GP32-l4WZQSx?zQE*yY*!G~p)!L$t7g~21S}zo< znSJcm_JUbnYgBKZopajq{?3BH-#XsCcA`cPby&7#GB$7OIL`UH<-v~^3Cav@AuUH$ zp7yjonB#Tfn6Al#M2E#Ml7*Qt8NRNTH$T1`%|NY_h zAG_9lxpVLMwq(T{@p)S>Y-hT>p6&n6yrZSF#U%EM{7jd+T$hCGvbbZ znKdK7{OHka*~{98?_RYP-;_N2pq8-SeD*bZ{M+mB_v-wKr00Y;M) zvNGhv49n46hYWZa9{irS=*hcHl>!s5#=6`Hea1XR?{{K!^sHZ-w{Ncy5apleB=kvD z^VDyfPVGv$H4`T+m*g#{M!mDvC~|TePWipW;^GpgIS=a!bd-`J zoaCEsD{gw188#=%U9BwYb-HNME9(-k{#D8`={MQ_?q-Z%u|kS-UsjR8*GDhp?`(U! z;AO(sR5jtcHwR{aRsQaxxad^5h{_(Xp7Rry2eS5De{j72_Y+{IW@M6MMwD3SwE)n^ z3_zbVENKL>u+#|<18^6MAZ-i`0t{~*4S?prLKxLj^kNdE17c|iTn{*i@mU5cgdhN9 znLaQ_fr}(mo6rj-kOqiN0T2zyv5L<&P|k(`kZm47%Yn9mG{f^bs>SFz9i$gxaS4WA zM83!80#KTT0FVnluz+2FJ$%vAFi0E3a&cB9ZODFy1~)6HW@F$4LRnz=uz-00g0}`9 literal 0 HcmV?d00001 diff --git a/scripts/gen-file-icons.mjs b/scripts/gen-file-icons.mjs new file mode 100644 index 0000000..4a51aaf --- /dev/null +++ b/scripts/gen-file-icons.mjs @@ -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 = { +${entries} +} +` +writeFileSync(new URL('../src/components/objectstorage/fileIcons.ts', import.meta.url), out) +console.log(`written ${PICK.length} icons`) diff --git a/src/api/aigateway.ts b/src/api/aigateway.ts index a06e98c..2c6d40c 100644 --- a/src/api/aigateway.ts +++ b/src/api/aigateway.ts @@ -1,4 +1,4 @@ -import { request } from './request' +import { mockOn, mocked, request } from './request' import type { AiCallLog, AiCatalogModel, @@ -49,6 +49,8 @@ export function updateAiKeyContentLog(id: number, hours: number): Promise // ---- 渠道(号池)---- export function listAiChannels(): Promise { + // mock 缺此分支时请求会打到真后端,mock token 401 导致演示模式被登出 + if (mockOn) return mocked([]) return request<{ items: AiChannel[] }>('/ai-channels').then((r) => r.items) } diff --git a/src/api/analytics.ts b/src/api/analytics.ts index c7399ce..3ff4a2c 100644 --- a/src/api/analytics.ts +++ b/src/api/analytics.ts @@ -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 { diff --git a/src/api/billing.ts b/src/api/billing.ts new file mode 100644 index 0000000..c835c21 --- /dev/null +++ b/src/api/billing.ts @@ -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 { + 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 { + 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 { + if (mockOn) return mocked(mockPaymentMethods) + return request(`/oci-configs/${id}/payment-methods`) +} + +/** 拉取发票 PDF 原文(接口带 JWT 不能裸链接);预览与下载共用 */ +export function fetchInvoicePdf(id: number, internalId: string): Promise { + 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) +} diff --git a/src/api/instances.ts b/src/api/instances.ts index 88801db..eec7a31 100644 --- a/src/api/instances.ts +++ b/src/api/instances.ts @@ -187,7 +187,35 @@ export function removeIpv6( // ---- VNIC 管理 ---- export function listVnics(cfgId: number, instanceId: string, region?: string): Promise { - 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 添加 IPv6;address 留空自动分配。删除复用实例级 removeIpv6(按地址遍历全部网卡) */ export function addVnicIpv6( cfgId: number, diff --git a/src/api/mock.ts b/src/api/mock.ts index d702a91..6f00134 100644 --- a/src/api/mock.ts +++ b/src/api/mock.ts @@ -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, @@ -569,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 }, ] @@ -1022,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 = { + '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': ` + + OCI + +`, +} + +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 = { 1: [ { @@ -1296,6 +1434,14 @@ export const mockCompartments: Record = { 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', + }, ], } @@ -1750,3 +1896,67 @@ export const mockLogWebhooks = new Map() // OCI 侧回传链路:mock 态记录「已一键创建」的租户 export const mockLogRelays = new Map() + +// ---- 账单:发票 / 明细 / 付款方式 ---- +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', + }, +] diff --git a/src/api/networks.ts b/src/api/networks.ts index afcf8ad..78eba72 100644 --- a/src/api/networks.ts +++ b/src/api/networks.ts @@ -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 { 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 { + 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, + }) +} diff --git a/src/api/objectstorage.ts b/src/api/objectstorage.ts new file mode 100644 index 0000000..ae2fb3d --- /dev/null +++ b/src/api/objectstorage.ts @@ -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 { + 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 { + 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 { + 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 { + if (mockOn) { + const prefix = opts.prefix ?? '' + const inScope = mockObjects.filter((o) => o.name.startsWith(prefix)) + const prefixes = new Set() + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { md: 'text/markdown', svg: 'image/svg+xml' } + +/** mock:文本走内容表,office/pdf 走 /mock-samples/ 静态样例 */ +async function mockGetContent(object: string): Promise { + 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 = {} + 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 { + 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 { + 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 { + 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; abort: () => void } { + const xhr = new XMLHttpRequest() + const promise = new Promise((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() } +} diff --git a/src/api/request.ts b/src/api/request.ts index cf77d61..b78b1a1 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -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 } } @@ -31,8 +34,15 @@ function buildUrl(path: string, query?: RequestOptions['query']): string { async function parseError(resp: Response): Promise { let message = `请求失败(${resp.status})` + let ociCode: string | undefined try { - const body = (await resp.json()) as { error?: string; hint?: string; errors?: unknown[] } + 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 { @@ -49,7 +59,7 @@ async function parseError(resp: Response): Promise { 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 */ @@ -63,8 +73,31 @@ export async function request(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 + body?: BodyInit + } = {}, +): Promise { + const auth = useAuthStore() + const headers: Record = { ...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' diff --git a/src/api/reservedips.ts b/src/api/reservedips.ts new file mode 100644 index 0000000..e648207 --- /dev/null +++ b/src/api/reservedips.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }, + }) +} diff --git a/src/assets/main.css b/src/assets/main.css index 0a30c7c..5c1d3e1 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -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 同步 */ @@ -296,3 +299,45 @@ 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; +} diff --git a/src/components/CompartmentSelect.vue b/src/components/CompartmentSelect.vue new file mode 100644 index 0000000..efc24ff --- /dev/null +++ b/src/components/CompartmentSelect.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/components/ConfirmPop.vue b/src/components/ConfirmPop.vue new file mode 100644 index 0000000..b36e940 --- /dev/null +++ b/src/components/ConfirmPop.vue @@ -0,0 +1,144 @@ + + + diff --git a/src/components/PanelHeader.vue b/src/components/PanelHeader.vue new file mode 100644 index 0000000..7e8ef54 --- /dev/null +++ b/src/components/PanelHeader.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/TenantPicker.vue b/src/components/TenantPicker.vue index d5f88de..27d92d6 100644 --- a/src/components/TenantPicker.vue +++ b/src/components/TenantPicker.vue @@ -1,12 +1,13 @@ + + diff --git a/src/components/instance/CreateInstanceModal.vue b/src/components/instance/CreateInstanceModal.vue index 16c108d..93aec2a 100644 --- a/src/components/instance/CreateInstanceModal.vue +++ b/src/components/instance/CreateInstanceModal.vue @@ -44,6 +44,11 @@ const canSubmit = computed( async function submit() { if (props.cfgId === null || !specForm.value) return + const spec = specForm.value.buildSpec() + if (spec.reservedPublicIpId && form.count > 1) { + message.warning('保留 IP 仅支持单台创建,请把数量改为 1') + return + } submitting.value = true try { const result = await createInstances(props.cfgId, { @@ -51,7 +56,7 @@ 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 diff --git a/src/components/instance/InstanceSpecForm.vue b/src/components/instance/InstanceSpecForm.vue index c77ef98..3632b3e 100644 --- a/src/components/instance/InstanceSpecForm.vue +++ b/src/components/instance/InstanceSpecForm.vue @@ -4,6 +4,8 @@ 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' @@ -42,6 +44,8 @@ const blank = { 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, @@ -173,6 +177,12 @@ function applySpec(spec: Record) { 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 : '' @@ -376,9 +386,33 @@ 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 }) +// ---- 公网 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] @@ -399,7 +433,9 @@ function buildSpec(): Record { 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: @@ -578,8 +614,23 @@ defineExpose({ valid, buildSpec, reset, applySpec, summary }) placeholder="ssh-ed25519 AAAA…" /> +
+ + + + + + +
- 分配公网 IPv4 分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }} diff --git a/src/components/instance/VnicPanel.vue b/src/components/instance/VnicPanel.vue index 7eec6a8..605d6f7 100644 --- a/src/components/instance/VnicPanel.vue +++ b/src/components/instance/VnicPanel.vue @@ -1,16 +1,19 @@ + + diff --git a/src/components/network/SecurityRuleEditor.vue b/src/components/network/SecurityRuleEditor.vue new file mode 100644 index 0000000..736e26d --- /dev/null +++ b/src/components/network/SecurityRuleEditor.vue @@ -0,0 +1,295 @@ + + + diff --git a/src/components/network/reservedIpSelect.ts b/src/components/network/reservedIpSelect.ts new file mode 100644 index 0000000..7f4f743 --- /dev/null +++ b/src/components/network/reservedIpSelect.ts @@ -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, + ]) +} diff --git a/src/components/objectstorage/FileIcon.vue b/src/components/objectstorage/FileIcon.vue new file mode 100644 index 0000000..a755e32 --- /dev/null +++ b/src/components/objectstorage/FileIcon.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/objectstorage/ObjectBrowser.vue b/src/components/objectstorage/ObjectBrowser.vue new file mode 100644 index 0000000..ce735ce --- /dev/null +++ b/src/components/objectstorage/ObjectBrowser.vue @@ -0,0 +1,660 @@ + + + diff --git a/src/components/objectstorage/ObjectViewerModal.vue b/src/components/objectstorage/ObjectViewerModal.vue new file mode 100644 index 0000000..b233a55 --- /dev/null +++ b/src/components/objectstorage/ObjectViewerModal.vue @@ -0,0 +1,517 @@ + + + diff --git a/src/components/objectstorage/ParPanel.vue b/src/components/objectstorage/ParPanel.vue new file mode 100644 index 0000000..095c9b9 --- /dev/null +++ b/src/components/objectstorage/ParPanel.vue @@ -0,0 +1,206 @@ + + + diff --git a/src/components/objectstorage/codeHighlight.ts b/src/components/objectstorage/codeHighlight.ts new file mode 100644 index 0000000..70c0e7f --- /dev/null +++ b/src/components/objectstorage/codeHighlight.ts @@ -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 = { + 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 = { + 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 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 { + 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 { + return highlightCode(text, ext) +} diff --git a/src/components/objectstorage/fileIcons.ts b/src/components/objectstorage/fileIcons.ts new file mode 100644 index 0000000..a8acda5 --- /dev/null +++ b/src/components/objectstorage/fileIcons.ts @@ -0,0 +1,60 @@ +// 由 scripts/gen-file-icons.mjs 从 @iconify-json/catppuccin 提取,勿手改; +// 来源:Catppuccin VSCode Icons(MIT),16x16 viewBox。 +export const FILE_ICON_BODIES: Record = { + "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": + '', +} diff --git a/src/components/objectstorage/format.ts b/src/components/objectstorage/format.ts new file mode 100644 index 0000000..44e38de --- /dev/null +++ b/src/components/objectstorage/format.ts @@ -0,0 +1,61 @@ +import type { Plugin } from 'prettier' + +/** prettier 各格式的 parser 与插件加载器;插件按需动态 import,各自成 chunk */ +const PRETTIER_CFG: Record Promise)[] }> = { + 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 { + 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 }) +} diff --git a/src/components/objectstorage/viewer/OfficePreview.vue b/src/components/objectstorage/viewer/OfficePreview.vue new file mode 100644 index 0000000..dfbf6a9 --- /dev/null +++ b/src/components/objectstorage/viewer/OfficePreview.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/src/components/objectstorage/viewer/TextEditPane.vue b/src/components/objectstorage/viewer/TextEditPane.vue new file mode 100644 index 0000000..86075b9 --- /dev/null +++ b/src/components/objectstorage/viewer/TextEditPane.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/components/objectstorage/viewer/TextPreviewPane.vue b/src/components/objectstorage/viewer/TextPreviewPane.vue new file mode 100644 index 0000000..c50fcc1 --- /dev/null +++ b/src/components/objectstorage/viewer/TextPreviewPane.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/components/proxy/ProxyPanel.vue b/src/components/proxy/ProxyPanel.vue index 921713b..67c210a 100644 --- a/src/components/proxy/ProxyPanel.vue +++ b/src/components/proxy/ProxyPanel.vue @@ -4,7 +4,6 @@ import { NDataTable, NInput, NModal, - NPopconfirm, NRadioButton, NRadioGroup, type DataTableColumns, @@ -19,6 +18,7 @@ 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' @@ -257,12 +257,11 @@ const columns: DataTableColumns = [ 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: () => '删除后不可恢复,确认?', }, ), ]), diff --git a/src/components/settings/AboutTab.vue b/src/components/settings/AboutTab.vue index dc31ebc..59e83fa 100644 --- a/src/components/settings/AboutTab.vue +++ b/src/components/settings/AboutTab.vue @@ -114,17 +114,17 @@ function openRepo() {
- 运行状态 + 运行状态
自 {{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
-
+
运行时间
-
- - 保存 - - 全部开关整体保存,立即生效 -
@@ -516,16 +543,18 @@ async function sendTest() {
熔断阈值(连续 NotAuthenticated 次数)
- 范围 1-10,默认 3;保存后下一次抢机执行即按新阈值判定 + 范围 1-10,默认 3;失焦即保存,下一次抢机执行按新阈值判定
@@ -545,12 +574,6 @@ async function sendTest() {
任务页手动启用
-
- - 保存 - - 保存后立即生效,无需重启 -
熔断只针对鉴权类失败(API Key 失效);容量不足等业务失败不计入,抢机会持续重试 @@ -570,16 +593,40 @@ async function sendTest() {
- + - + - + - +
@@ -629,6 +676,7 @@ async function sendTest() { v-model:value="securityForm.realIpHeader" class="mono mt-2 !w-70" placeholder="自定义头名,如 X-Client-IP" + @change="() => void saveSecurity()" />
头名仅限字母数字与连字符 @@ -638,14 +686,12 @@ async function sendTest() { label="面板地址" hint="面板公网基址,优先于 PUBLIC_URL 环境变量;日志回传一键创建与 OAuth 回调以此拼接 URL" > - + -
- - 保存 - - 整体保存,立即生效,无需重启 -
登录锁定为内存计数,重启服务即清零;真实IP解析对 X-Forwarded-For diff --git a/src/views/TaskDetailView.vue b/src/views/TaskDetailView.vue index c492619..af15c15 100644 --- a/src/views/TaskDetailView.vue +++ b/src/views/TaskDetailView.vue @@ -5,6 +5,7 @@ import { RouterLink, useRoute, useRouter } from 'vue-router' import { deleteTask, getTask, listTaskLogs, runTask, updateTask } from '@/api/tasks' import EmptyCard from '@/components/EmptyCard.vue' +import PanelHeader from '@/components/PanelHeader.vue' import StatusBadge from '@/components/StatusBadge.vue' import TaskFormModal from '@/components/task/TaskFormModal.vue' import TaskTypeIcon from '@/components/task/TaskTypeIcon.vue' @@ -146,8 +147,8 @@ const features = computed(() => { > -
- {{ task.error.value }} +
+
{
-
-
事件流
-
最新事件置顶 · 保留近 100 条
-
-
+ + 最新事件置顶 · 保留近 100 条 + +
{
-
任务参数
-
+ +
{
-
运维特性
-
+ +
-import { NButton, NPopconfirm, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui' +import { NButton, NSelect, NSpin, NTabPane, NTabs, NTooltip } from 'naive-ui' import { computed, ref, watch } from 'vue' import { RouterLink, useRoute, useRouter } from 'vue-router' @@ -7,7 +7,10 @@ import { deleteConfig, getConfig, verifyConfig } from '@/api/configs' import { listIdentityDomains } from '@/api/tenant' import AccountTypeBadge from '@/components/AccountTypeBadge.vue' import AuditLogTab from '@/components/tenant/AuditLogTab.vue' +import ConfirmPop from '@/components/ConfirmPop.vue' +import EmptyCard from '@/components/EmptyCard.vue' import CostTab from '@/components/tenant/CostTab.vue' +import InvoiceTab from '@/components/tenant/InvoiceTab.vue' import DeadPlaceholder from '@/components/DeadPlaceholder.vue' import IdpTab from '@/components/tenant/IdpTab.vue' import MiscTab from '@/components/tenant/MiscTab.vue' @@ -98,11 +101,8 @@ async function remove() { >
-
- {{ config.error.value }} +
+
@@ -136,20 +136,18 @@ async function remove() {
立即测活 - + -
-
删除「{{ config.data.value.alias }}」?
-
- 将清理面板内关联数据:后台任务、测活 / 成本快照、回传事件、Webhook 密钥与 AI 渠道。 -
-
- OCI 云端资源不受影响;云端日志回传链路请先在「其他」页销毁。 -
-
-
+
@@ -176,6 +174,7 @@ async function remove() { + @@ -200,6 +199,7 @@ async function remove() { + diff --git a/src/views/TenantListView.vue b/src/views/TenantListView.vue index 6b427d6..152ba4e 100644 --- a/src/views/TenantListView.vue +++ b/src/views/TenantListView.vue @@ -1,11 +1,12 @@