@@ -28,6 +28,10 @@ npm run build # 类型检查 + 产物构建到 dist/
|
||||
1. **静态直出**:`dist/` 交给 Caddy/nginx 伺服,`/api/*`、`/ai/*` 反代到后端
|
||||
2. **单文件嵌入**:产物经后端 `go:embed` 打进单个二进制。本仓库 Release 流程(tag `v*`)自动构建并发布 `dist.zip`,后端 Release 流程从这里下载嵌入;本地则 `npm run build` 后把 `dist/` 内容拷入后端 `internal/webui/dist/`
|
||||
|
||||
### PWA
|
||||
|
||||
构建产物自带 PWA(vite-plugin-pwa,`manifest.webmanifest` + Service Worker),HTTPS 部署下手机浏览器可「添加到主屏幕」以独立窗口运行;前端发版后用户刷新即自动更新(`registerType: autoUpdate`)。Service Worker 只预缓存应用壳(约 4.6MB,office 预览大块按需缓存),`/api/*`、`/ai/*` 一律直连不缓存。图标源自 `public/favicon.svg`,改版时需同步重生成 `public/pwa-*.png` 与 `apple-touch-icon.png`。
|
||||
|
||||
## 规范
|
||||
|
||||
编码规范与项目约定见 [AGENTS.md](AGENTS.md) 与 [.trellis/spec/](.trellis/spec/)。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="theme-color" content="#F5F4ED" />
|
||||
<title>OCI Portal</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+4611
-2
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,7 @@
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -84,6 +84,42 @@ body {
|
||||
background: color-mix(in srgb, var(--color-ink) 38%, transparent);
|
||||
}
|
||||
|
||||
/* 表格滚动条常显:Naive 隐藏原生条(scrollbar-width:none)后全靠自绘 thumb,
|
||||
而自绘条仅悬停时短暂出现且渲染链路脆弱,窄容器下用户无从得知表格可横滚。
|
||||
现代 Chromium(121+)只认标准属性——computed 为 none 时 ::-webkit-* 规则
|
||||
整体失效,故必须以更高特异性覆盖 scrollbar-width 本身(样式随全局瘦滚动条);
|
||||
webkit 伪元素规则兜底老内核(Naive 把 scrollbar/thumb 清零,须逐项放开) */
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container::-webkit-scrollbar:horizontal {
|
||||
display: block;
|
||||
height: 7px;
|
||||
background: transparent;
|
||||
}
|
||||
.n-data-table .n-scrollbar > .n-scrollbar-container::-webkit-scrollbar-thumb:horizontal {
|
||||
display: block;
|
||||
height: 7px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-ink) 22%, transparent);
|
||||
}
|
||||
|
||||
/* 租户选择器面板(移动端):popover follower 只会翻转不会平移,窄屏下
|
||||
bottom-start / bottom-end 两个方向都放不下整块面板,必然被视口裁切;
|
||||
改为覆盖 follower 定位,固定顶部整宽展示(follower 挂 body 下,无
|
||||
transform 祖先,fixed 相对视口生效) */
|
||||
@media (max-width: 767px) {
|
||||
.v-binder-follower-content:has(.tenant-picker-panel) {
|
||||
position: fixed !important;
|
||||
inset: 60px 12px auto 12px !important;
|
||||
transform: none !important;
|
||||
}
|
||||
.v-binder-follower-content:has(.tenant-picker-panel) .n-popover,
|
||||
.tenant-picker-panel {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 页面标题统一 serif */
|
||||
.page-title {
|
||||
font-family: var(--font-serif);
|
||||
@@ -341,3 +377,50 @@ html.theme-switching *::after {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 移动端:Tab 条横向滑动;右缘渐隐暗示还有更多 Tab(Naive 无内建暗示),
|
||||
滚动内容补尾部内边距,滚到底时末个 Tab 不被渐隐吃掉 */
|
||||
@media (max-width: 767px) {
|
||||
.n-tabs .n-tabs-nav-scroll-wrapper {
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent);
|
||||
}
|
||||
.n-tabs .n-tabs-nav-scroll-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.n-tabs .n-tabs-nav-scroll-content {
|
||||
padding-right: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗移动端全屏形态,FormModal 与各详情/表单弹窗共用(配 useMobileModal):
|
||||
NModal teleport 到 body,组件 scoped 样式够不到,且按路由分包后单组件的
|
||||
非 scoped 样式不保证已加载,故规则放全局。内容区吃满剩余高度限内滚动,
|
||||
页脚实底常驻含手势区安全距。内容区类名此版本为 .n-card-content(footer
|
||||
却是 .n-card__footer),两种拼写都盖住防升级回切 */
|
||||
.form-modal-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.form-modal-mobile .form-modal-body {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.form-modal-mobile .n-card-content,
|
||||
.form-modal-mobile .n-card__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* 无内层滚动容器的弹窗(详情类)内容直接在此滚;FormModal 内层
|
||||
.form-modal-body 自会收缩到容器内,不触发此层滚动 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
.form-modal-mobile .n-card__footer {
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
background: var(--color-card);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
/** 筛选器统一布局:桌面 flex-wrap 换行,移动端横滚一行 + 右缘渐隐暗示,
|
||||
* 条目不换行不压缩(替代 max-md:!w-full 逐行堆叠吃满竖向空间的旧模式) */
|
||||
const isMobile = useIsMobile()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="isMobile ? 'filter-scroll' : 'flex flex-wrap items-center gap-x-3 gap-y-2'">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
|
||||
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
|
||||
padding-right: 24px;
|
||||
}
|
||||
.filter-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.filter-scroll > :deep(*) {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NModal } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
title: string
|
||||
submitting?: boolean
|
||||
@@ -12,6 +15,15 @@ defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
/** 移动端全屏(100dvh 覆盖底栏与手势区),桌面维持定宽居中 */
|
||||
const modalStyle = computed(() =>
|
||||
isMobile.value
|
||||
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
|
||||
: { width: `${props.width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -19,7 +31,8 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="title"
|
||||
:style="{ width: `${width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="isMobile ? 'form-modal-mobile' : ''"
|
||||
:mask-closable="!submitting"
|
||||
:close-on-esc="!submitting"
|
||||
@update:show="emit('update:show', $event)"
|
||||
@@ -33,11 +46,17 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2.5">
|
||||
<NButton size="small" :disabled="submitting" @click="emit('update:show', false)">
|
||||
<NButton
|
||||
:size="isMobile ? 'medium' : 'small'"
|
||||
:class="isMobile ? 'flex-1' : ''"
|
||||
:disabled="submitting"
|
||||
@click="emit('update:show', false)"
|
||||
>
|
||||
取消
|
||||
</NButton>
|
||||
<NButton
|
||||
size="small"
|
||||
:size="isMobile ? 'medium' : 'small'"
|
||||
:class="isMobile ? 'flex-1' : ''"
|
||||
:type="danger ? 'error' : 'primary'"
|
||||
:loading="submitting"
|
||||
:disabled="submitDisabled"
|
||||
@@ -54,9 +73,19 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
|
||||
/* 长表单不撑破视口:内容区限高滚动,页脚按钮常驻;
|
||||
负 margin + 等量 padding 让滚动条贴弹窗边缘(变量继承自 .n-card) */
|
||||
.form-modal-body {
|
||||
max-height: min(62vh, calc(100vh - 220px));
|
||||
overflow-y: auto;
|
||||
margin-inline: calc(-1 * var(--n-padding-left, 24px));
|
||||
padding-inline: var(--n-padding-left, 24px);
|
||||
}
|
||||
/* 限高只在桌面定宽形态生效:移动全屏由全局 .form-modal-mobile 的 flex:1
|
||||
接管高度。不能靠全局规则 max-height:none 覆盖——main.css 先于组件
|
||||
scoped 样式加载,同特异性下这里会反超,故直接用断点隔离 */
|
||||
@media (min-width: 768px) {
|
||||
.form-modal-body {
|
||||
max-height: min(62vh, calc(100vh - 220px));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 移动端全屏形态 .form-modal-mobile 的规则在 main.css(全局):
|
||||
NModal teleport 到 body,scoped 够不到;且该类被各弹窗跨 chunk 复用 -->
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { NSpin } from 'naive-ui'
|
||||
|
||||
/** 移动端列表卡片化容器:接管加载 / 空态,卡片体由默认插槽渲染 */
|
||||
const props = defineProps<{
|
||||
items: T[]
|
||||
loading?: boolean
|
||||
itemKey: (item: T) => string | number
|
||||
emptyText?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-if="props.loading && !props.items.length"
|
||||
class="panel flex items-center justify-center py-10"
|
||||
>
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<div v-else-if="!props.items.length" class="panel py-10 text-center text-[13px] text-ink-3">
|
||||
{{ props.emptyText ?? '暂无数据' }}
|
||||
</div>
|
||||
<div v-for="it in props.items" v-else :key="props.itemKey(it)" class="panel px-4 py-3">
|
||||
<slot :item="it"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,25 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
/** 面板标准头:统一 px-4.5 py-3.5 内距、text-sm 标题、text-xs 弱化描述。
|
||||
* 右侧动作放默认插槽;标题行内的徽标/计数放 #title-extra;富文本描述放 #desc。 */
|
||||
* 右侧动作放默认插槽——固定在标题行右端,desc 独立成行,窄屏下按钮
|
||||
* 不随描述折行漂移(移动端按钮位置统一);标题行内的徽标/计数放
|
||||
* #title-extra;富文本描述放 #desc。 */
|
||||
defineProps<{ title: string; desc?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm font-semibold">
|
||||
<span class="truncate">{{ title }}</span>
|
||||
<slot name="title-extra" />
|
||||
</div>
|
||||
<div v-if="desc || $slots.desc" class="mt-0.5 text-xs text-ink-3">
|
||||
<template v-if="desc">{{ desc }}</template>
|
||||
<slot name="desc" />
|
||||
<div v-if="$slots.default" class="flex flex-none items-center gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.default" class="flex flex-wrap items-center gap-2">
|
||||
<slot />
|
||||
<div v-if="desc || $slots.desc" class="mt-0.5 text-xs text-ink-3">
|
||||
<template v-if="desc">{{ desc }}</template>
|
||||
<slot name="desc" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { NPopover } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
|
||||
/** 租户悬停卡(AI 网关渠道 / 回传日志共用):别名、租户名称、主区域三行
|
||||
* 加「前往租户」入口;触发文本本身不承载跳转,避免误触。 */
|
||||
const props = defineProps<{
|
||||
cfgId: number
|
||||
/** 触发文本(渠道名 / 租户别名) */
|
||||
label: string
|
||||
alias?: string
|
||||
tenancyName?: string
|
||||
region?: string
|
||||
/** 触发文本附加类(截断约束等) */
|
||||
labelClass?: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
function go(e: MouseEvent) {
|
||||
// 表格行自身可能有点击行为(如回传日志展开详情),跳转不冒泡
|
||||
e.stopPropagation()
|
||||
void router.push({ name: 'tenant-detail', params: { id: props.cfgId } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NPopover trigger="hover" placement="top-start" :style="{ maxWidth: '320px' }">
|
||||
<template #trigger>
|
||||
<span
|
||||
class="cursor-help border-b border-dashed border-ink-3/50 text-[13px]"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex min-w-40 flex-col text-xs">
|
||||
<div class="pb-1.5 text-[13px] font-semibold break-all">{{ alias ?? label }}</div>
|
||||
<div v-if="tenancyName" class="mono border-t border-line-soft py-1.5 break-all text-ink-2">
|
||||
{{ tenancyName }}
|
||||
</div>
|
||||
<div v-if="region" class="border-t border-line-soft py-1.5 text-ink-2">
|
||||
{{ regionAlias(region) }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer border-t border-line-soft pt-1.5 text-left font-medium text-accent"
|
||||
@click="go"
|
||||
>
|
||||
前往租户
|
||||
</button>
|
||||
</div>
|
||||
</NPopover>
|
||||
</template>
|
||||
@@ -143,7 +143,7 @@ const showClear = computed(
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<div class="w-[min(560px,92vw)] overflow-hidden rounded-lg border border-line bg-card shadow-overlay">
|
||||
<div class="tenant-picker-panel w-[min(560px,92vw)] overflow-hidden rounded-lg border border-line bg-card shadow-overlay">
|
||||
<div class="px-3.5 pt-3 pb-2.5">
|
||||
<NInput ref="searchInput" v-model:value="query" size="small" placeholder="搜索租户别名 / 租户名称…" clearable>
|
||||
<template #prefix>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
syncAiChannelModels,
|
||||
testAiChannelModel,
|
||||
} from '@/api/aigateway'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiChannel, AiModelCacheItem } from '@/types/api'
|
||||
|
||||
@@ -16,6 +17,7 @@ const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const rows = ref<AiModelCacheItem[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -121,7 +123,8 @@ function confirmBlacklist(name: string) {
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -11,6 +11,7 @@ import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TenantHoverCard from '@/components/TenantHoverCard.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtRelative } from '@/composables/useFormat'
|
||||
@@ -26,6 +27,20 @@ const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group)
|
||||
const toast = useToast()
|
||||
const dialog = useDialog()
|
||||
const scope = useScopeStore()
|
||||
/** 名称列:渠道名关联租户,悬停出统一租户卡(别名/名称/主区域/前往租户);
|
||||
* 名称本身不承载跳转,单元格内截断 */
|
||||
function nameCell(r: AiChannel) {
|
||||
// scope 经 Pinia reactive 包装,useAsync 的 data ref 已深层解包,不能再 .value
|
||||
const cfg = (scope.configs.data ?? []).find((c) => c.id === r.ociConfigId)
|
||||
return h(TenantHoverCard, {
|
||||
cfgId: r.ociConfigId,
|
||||
label: r.name,
|
||||
alias: cfg?.alias,
|
||||
tenancyName: cfg?.tenancyName,
|
||||
region: cfg?.region,
|
||||
labelClass: 'inline-block max-w-full truncate align-bottom font-medium',
|
||||
})
|
||||
}
|
||||
|
||||
const PROBE_META: Record<Exclude<AiProbeStatus, ''>, { kind: 'run' | 'warn' | 'term' | 'stop'; label: string }> = {
|
||||
ok: { kind: 'run', label: '可用' },
|
||||
@@ -169,7 +184,7 @@ function probeBadge(r: AiChannel) {
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
{ title: '名称', key: 'name', minWidth: 200, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
|
||||
{ title: '名称', key: 'name', minWidth: 200, render: nameCell },
|
||||
{ title: '区域', key: 'region', width: 150, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs text-ink-2' }, r.region) },
|
||||
{
|
||||
title: '分组', key: 'group', width: 100,
|
||||
@@ -201,14 +216,13 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
|
||||
<div class="panel">
|
||||
<PanelHeader
|
||||
title="渠道(号池)"
|
||||
desc="渠道 = 租户 × 区域;请求按优先级分组、组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断"
|
||||
desc="渠道 = 租户 × 区域;按优先级与权重分流,失败自动重试并熔断"
|
||||
>
|
||||
<NButton size="small" type="primary" @click="openCreate">添加渠道</NButton>
|
||||
</PanelHeader>
|
||||
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" />
|
||||
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" :scroll-x="960" />
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
探测三步:列模型(服务可见性)→ 同步模型缓存 → max_tokens=16 试调(配额)· 探测成功自动复位熔断 ·
|
||||
有渠道时系统自动维护「AI渠道探测」后台任务(每天 00:00 逐渠道探测,渠道清空自动暂停)
|
||||
探测 = 列模型 → 同步缓存 → 试调验证配额;成功自动复位熔断 · 后台任务每日 00:00 自动逐渠道探测
|
||||
</div>
|
||||
|
||||
<AiChannelModelsModal
|
||||
|
||||
@@ -172,11 +172,11 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
|
||||
<div class="panel">
|
||||
<PanelHeader
|
||||
title="网关密钥"
|
||||
desc="对外发放的访问凭证;明文仅创建时展示一次,库内只存哈希与尾 4 位;选了分组的密钥只在该分组渠道内负载均衡"
|
||||
desc="对外发放的访问凭证;明文仅创建时展示一次;分组密钥只走该分组渠道"
|
||||
>
|
||||
<NButton size="small" type="primary" @click="openCreate">新建密钥</NButton>
|
||||
</PanelHeader>
|
||||
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" />
|
||||
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" :scroll-x="1110" />
|
||||
|
||||
<FormModal
|
||||
v-model:show="showForm"
|
||||
|
||||
@@ -37,13 +37,25 @@ const daysOptions = [
|
||||
]
|
||||
|
||||
const series = computed(() => {
|
||||
const vnic = traffic.data.value?.vnics[0]
|
||||
if (!vnic) return null
|
||||
const labels = vnic.outbound.map((p) => p.timestamp.slice(5, 10))
|
||||
const vnics = traffic.data.value?.vnics ?? []
|
||||
if (!vnics.length) return null
|
||||
// 各 VNIC 时间序列长短不一(新挂网卡只有近几天),按日取并集逐日求和
|
||||
const byDay = new Map<string, { inbound: number; outbound: number }>()
|
||||
const bucket = (ts: string) => {
|
||||
const key = ts.slice(0, 10)
|
||||
let b = byDay.get(key)
|
||||
if (!b) byDay.set(key, (b = { inbound: 0, outbound: 0 }))
|
||||
return b
|
||||
}
|
||||
for (const v of vnics) {
|
||||
for (const p of v.inbound) bucket(p.timestamp).inbound += p.bytes
|
||||
for (const p of v.outbound) bucket(p.timestamp).outbound += p.bytes
|
||||
}
|
||||
const days = [...byDay.keys()].sort()
|
||||
return {
|
||||
labels,
|
||||
outbound: vnic.outbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
inbound: vnic.inbound.map((p) => +(p.bytes / 1024 ** 3).toFixed(2)),
|
||||
labels: days.map((d) => d.slice(5)),
|
||||
outbound: days.map((d) => +(byDay.get(d)!.outbound / 1024 ** 3).toFixed(2)),
|
||||
inbound: days.map((d) => +(byDay.get(d)!.inbound / 1024 ** 3).toFixed(2)),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">网卡(VNIC)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
实例的全部虚拟网卡;每张网卡可各自附加多个 IPv6
|
||||
实例的全部虚拟网卡
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :disabled="disabled" @click="showAttach = true">
|
||||
@@ -296,8 +296,7 @@ defineExpose({ refresh: () => vnics.run() })
|
||||
</div>
|
||||
|
||||
<FootNote>
|
||||
附加须实例运行中且 shape 有空余 VNIC 配额 · 主网卡不可分离 · 每卡 IPv6
|
||||
可多个(要求所在子网已启用 IPv6)
|
||||
附加须实例运行中且 shape 有空余 VNIC 配额
|
||||
</FootNote>
|
||||
|
||||
<AttachVnicModal
|
||||
|
||||
@@ -9,10 +9,14 @@ import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { AiCallLog, AiContentLog } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(600)
|
||||
const rows = ref<AiCallLog[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -22,6 +26,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -197,7 +205,7 @@ const footerText = computed(() => {
|
||||
<div class="panel">
|
||||
<PanelHeader
|
||||
title="AI 调用日志"
|
||||
desc="仅元数据与 token 用量,不记录对话内容;保留 90 天 / 5 万行 · 每 5 秒自动刷新"
|
||||
desc="仅元数据与 token 用量;保留 90 天 / 5 万行"
|
||||
/>
|
||||
<NDataTable
|
||||
remote
|
||||
@@ -207,10 +215,11 @@ const footerText = computed(() => {
|
||||
:pagination="pagination"
|
||||
:bordered="false"
|
||||
:row-props="rowProps"
|
||||
:scroll-x="1230"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { NDataTable, NModal, NPopover, type DataTableColumns } from 'naive-ui'
|
||||
import { NDataTable, NModal, type DataTableColumns } from 'naive-ui'
|
||||
import { computed, h, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { listConfigs } from '@/api/configs'
|
||||
import { listLogEvents } from '@/api/logevents'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import TenantHoverCard from '@/components/TenantHoverCard.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { eventLabel, eventTail } from '@/composables/useEventLabel'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
import type { LogEvent, OciConfigSummary } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(720)
|
||||
const message = useToast()
|
||||
const router = useRouter()
|
||||
const scope = useScopeStore()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
const rows = ref<LogEvent[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -47,6 +49,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -86,35 +92,17 @@ const aliasById = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
/** 租户单元格:点击跳租户详情(阻止冒泡到行详情);hover 只展示值不加标签 */
|
||||
/** 租户单元格:悬停出统一租户卡(别名/名称/主区域/前往租户),名称本身不跳转 */
|
||||
function renderTenant(row: LogEvent) {
|
||||
const label = aliasById.value.get(row.ociConfigId) ?? `#${row.ociConfigId}`
|
||||
const cfg = cfgById.value.get(row.ociConfigId)
|
||||
const link = h(
|
||||
'span',
|
||||
{
|
||||
class: 'cursor-pointer border-b border-dashed border-ink-3/50 text-[13px] hover:text-accent',
|
||||
onClick: (e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
void router.push({ name: 'tenant-detail', params: { id: row.ociConfigId } })
|
||||
},
|
||||
},
|
||||
return h(TenantHoverCard, {
|
||||
cfgId: row.ociConfigId,
|
||||
label,
|
||||
)
|
||||
if (!cfg) return link
|
||||
return h(
|
||||
NPopover,
|
||||
{ trigger: 'hover', placement: 'right' },
|
||||
{
|
||||
trigger: () => link,
|
||||
default: () =>
|
||||
h('div', { class: 'flex flex-col gap-1 text-xs' }, [
|
||||
h('div', { class: 'font-medium text-[13px]' }, cfg.alias),
|
||||
h('div', { class: 'mono text-ink-2' }, cfg.tenancyName),
|
||||
h('div', { class: 'text-ink-2' }, regionAlias(cfg.region)),
|
||||
]),
|
||||
},
|
||||
)
|
||||
alias: cfg?.alias,
|
||||
tenancyName: cfg?.tenancyName,
|
||||
region: cfg?.region,
|
||||
})
|
||||
}
|
||||
|
||||
/** 摘要头标题:事件中文名 → 类型尾段 → 解析状态兜底 */
|
||||
@@ -212,28 +200,32 @@ useAutoRefresh(() => load(true))
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">回传日志</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
OCI 侧推送的关键审计事件(Connector Hub → Notifications → 面板 webhook)
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<TenantPicker
|
||||
v-model:value="cfgFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部租户"
|
||||
class="w-52"
|
||||
placement="bottom-end"
|
||||
:configs="scope.configs.data ?? []"
|
||||
:loading="scope.configs.loading"
|
||||
@update:value="onFilter"
|
||||
/>
|
||||
</div>
|
||||
<PanelHeader title="回传日志" desc="OCI 侧推送的关键审计事件">
|
||||
<TenantPicker
|
||||
v-if="!isMobile"
|
||||
v-model:value="cfgFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部租户"
|
||||
class="w-52"
|
||||
placement="bottom-end"
|
||||
:configs="scope.configs.data ?? []"
|
||||
:loading="scope.configs.loading"
|
||||
@update:value="onFilter"
|
||||
/>
|
||||
</PanelHeader>
|
||||
<!-- 移动端筛选独立成行全宽,不与标题挤同一行 -->
|
||||
<div v-if="isMobile" class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<TenantPicker
|
||||
v-model:value="cfgFilter"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="全部租户"
|
||||
class="w-full"
|
||||
:configs="scope.configs.data ?? []"
|
||||
:loading="scope.configs.loading"
|
||||
@update:value="onFilter"
|
||||
/>
|
||||
</div>
|
||||
<NDataTable
|
||||
remote
|
||||
@@ -245,7 +237,7 @@ useAutoRefresh(() => load(true))
|
||||
:row-key="(r: LogEvent) => r.id"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -6,13 +6,18 @@ import { listSystemLogs } from '@/api/settings'
|
||||
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
|
||||
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
|
||||
import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import PanelHeader from '@/components/PanelHeader.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { actionLabel } from '@/composables/useActionLabel'
|
||||
import { useAutoRefresh } from '@/composables/useAutoRefresh'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { SystemLog } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
const message = useToast()
|
||||
|
||||
const rows = ref<SystemLog[]>([])
|
||||
@@ -28,6 +33,10 @@ const pagination = reactive({
|
||||
itemCount: 0,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
void load()
|
||||
@@ -160,19 +169,25 @@ useAutoRefresh(() => load(true))
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">系统日志</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">面板写操作与登录成败留痕,用于审计与排障</div>
|
||||
</div>
|
||||
<PanelHeader title="系统日志" desc="面板写操作与登录成败留痕,用于审计与排障">
|
||||
<NInput
|
||||
v-if="!isMobile"
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索路径 / 用户名"
|
||||
class="!w-64"
|
||||
@keyup.enter="search"
|
||||
@clear="onClear"
|
||||
/>
|
||||
</PanelHeader>
|
||||
<!-- 移动端筛选独立成行全宽,不与标题挤同一行 -->
|
||||
<div v-if="isMobile" class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<NInput
|
||||
v-model:value="keyword"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索路径 / 用户名,回车确认"
|
||||
class="!max-w-64"
|
||||
placeholder="搜索路径 / 用户名"
|
||||
@keyup.enter="search"
|
||||
@clear="onClear"
|
||||
/>
|
||||
@@ -187,7 +202,7 @@ useAutoRefresh(() => load(true))
|
||||
:row-key="(r: SystemLog) => r.id"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -8,11 +8,13 @@ import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { ReservedIp } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number | null; region?: string; compartmentId?: string }>()
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(420)
|
||||
|
||||
const ips = useAsync<ReservedIp[]>(async () => {
|
||||
if (!props.cfgId) return []
|
||||
@@ -194,11 +196,11 @@ const columns = computed<DataTableColumns<ReservedIp>>(() => [
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div>
|
||||
<div class="text-sm font-semibold">保留 IP</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
区域级固定公网 IP;换绑会先释放实例原临时 IP,创建实例时也可直接选用
|
||||
区域级固定公网 IP,可换绑实例或创建实例时选用
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" :loading="creating" @click="create">创建保留 IP</NButton>
|
||||
@@ -215,7 +217,8 @@ const columns = computed<DataTableColumns<ReservedIp>>(() => [
|
||||
v-model:show="showBind"
|
||||
preset="card"
|
||||
title="绑定保留 IP 到实例"
|
||||
:style="{ width: '420px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-[13px] text-ink-2">
|
||||
|
||||
@@ -4,9 +4,10 @@ import {
|
||||
NDataTable,
|
||||
NDropdown,
|
||||
NInput,
|
||||
NProgress,
|
||||
NRadio,
|
||||
NRadioGroup,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
@@ -26,25 +27,30 @@ import FormModal from '@/components/FormModal.vue'
|
||||
import FileIcon from '@/components/objectstorage/FileIcon.vue'
|
||||
import ObjectViewerModal from '@/components/objectstorage/ObjectViewerModal.vue'
|
||||
import ParPanel from '@/components/objectstorage/ParPanel.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { ObjectSummary } from '@/types/api'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import type { Bucket, ObjectSummary } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
bucket: string
|
||||
/** 桶版本控制状态,编辑保存确认用;未知按未开启保守处理 */
|
||||
versioningOn?: boolean
|
||||
/** 桶可见性与 namespace,透传查看器做公网 URL 复制 */
|
||||
visibility?: string
|
||||
namespace?: string
|
||||
/** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
|
||||
info?: Bucket
|
||||
}>()
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
const emit = defineEmits<{ back: []; settings: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** hero 与 Tab:对象浏览 / 分享链接(PAR) */
|
||||
const tab = ref<'objects' | 'par'>('objects')
|
||||
/** 移动端 hero meta 默认收起,与租户/实例/VCN 详情一致 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const prefix = ref('')
|
||||
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
|
||||
@@ -113,14 +119,50 @@ function prevPage() {
|
||||
cursor.value = cursorStack.value.pop() ?? ''
|
||||
}
|
||||
|
||||
// ---- 上传:PAR 直传,逐文件进度 ----
|
||||
interface UploadTask {
|
||||
// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
|
||||
interface TransferTask {
|
||||
id: number
|
||||
kind: 'up' | 'down'
|
||||
name: string
|
||||
percent: number
|
||||
status: 'active' | 'done' | 'error'
|
||||
/** 附注:如「已交由浏览器下载」(无进度) */
|
||||
note?: string
|
||||
error?: string
|
||||
abort: () => void
|
||||
retry: () => void
|
||||
}
|
||||
const uploads = ref<UploadTask[]>([])
|
||||
const transfers = ref<TransferTask[]>([])
|
||||
const transfersCollapsed = ref(false)
|
||||
let taskSeq = 0
|
||||
|
||||
const activeCount = computed(() => transfers.value.filter((t) => t.status === 'active').length)
|
||||
const doneCount = computed(() => transfers.value.filter((t) => t.status === 'done').length)
|
||||
|
||||
function newTask(kind: 'up' | 'down', name: string, retry: () => void): TransferTask {
|
||||
const task = reactive<TransferTask>({
|
||||
id: ++taskSeq, kind, name, percent: 0, status: 'active', abort: () => {}, retry,
|
||||
})
|
||||
transfers.value.push(task)
|
||||
transfersCollapsed.value = false
|
||||
return task
|
||||
}
|
||||
|
||||
function removeTask(task: TransferTask) {
|
||||
task.abort()
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
}
|
||||
|
||||
function retryTask(task: TransferTask) {
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
task.retry()
|
||||
}
|
||||
|
||||
function clearDone() {
|
||||
transfers.value = transfers.value.filter((t) => t.status !== 'done')
|
||||
}
|
||||
|
||||
// ---- 上传:PAR 直传,逐文件进度 ----
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickFiles() {
|
||||
@@ -134,11 +176,14 @@ async function onFilesPicked(e: Event) {
|
||||
void result.run({ silent: true })
|
||||
}
|
||||
|
||||
async function uploadOne(file: File) {
|
||||
/** objectName 进闭包:重试时不受当前浏览层级变化影响 */
|
||||
function uploadOne(file: File, objectName = prefix.value + file.name) {
|
||||
const task = newTask('up', file.name, () => uploadOne(file, objectName))
|
||||
return doUpload(task, objectName, file)
|
||||
}
|
||||
|
||||
async function doUpload(task: TransferTask, objectName: string, file: File) {
|
||||
if (!props.cfgId) return
|
||||
const objectName = prefix.value + file.name
|
||||
const task = reactive<UploadTask>({ name: file.name, percent: 0, abort: () => {} })
|
||||
uploads.value.push(task)
|
||||
let parId = ''
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
@@ -151,9 +196,10 @@ async function uploadOne(file: File) {
|
||||
const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
|
||||
task.abort = abort
|
||||
await promise
|
||||
uploads.value = uploads.value.filter((t) => t !== task)
|
||||
message.success(`${file.name} 上传完成`)
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
} catch (err) {
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '上传失败'
|
||||
} finally {
|
||||
if (parId) void cleanupPar(parId)
|
||||
@@ -171,9 +217,81 @@ async function cleanupPar(parId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function dismissUpload(task: UploadTask) {
|
||||
task.abort()
|
||||
uploads.value = uploads.value.filter((t) => t !== task)
|
||||
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
|
||||
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
|
||||
|
||||
function download(name: string, size?: number) {
|
||||
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
|
||||
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
|
||||
return doDownload(task, name, known)
|
||||
}
|
||||
|
||||
async function doDownload(task: TransferTask, name: string, size: number) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 1,
|
||||
})
|
||||
const cleanable = await transferDown(task, par.fullUrl!, size)
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
|
||||
if (cleanable) void cleanupPar(par.id)
|
||||
else parPanel.value?.refresh()
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '下载失败'
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
|
||||
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
|
||||
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
|
||||
try {
|
||||
const blob = await fetchWithProgress(task, url, size)
|
||||
saveBlob(blob, task.name)
|
||||
return true
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') throw err
|
||||
return browserOpen(task, url)
|
||||
}
|
||||
}
|
||||
|
||||
function browserOpen(task: TransferTask, url: string): boolean {
|
||||
window.open(url, '_blank')
|
||||
task.note = '已交由浏览器下载'
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise<Blob> {
|
||||
const ctrl = new AbortController()
|
||||
task.abort = () => ctrl.abort()
|
||||
const resp = await fetch(url, { signal: ctrl.signal })
|
||||
if (!resp.ok || !resp.body) throw new Error(`下载失败(HTTP ${resp.status})`)
|
||||
const total = Number(resp.headers.get('Content-Length')) || size
|
||||
const reader = resp.body.getReader()
|
||||
const chunks: BlobPart[] = []
|
||||
let loaded = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
loaded += value.byteLength
|
||||
task.percent = Math.min(99, Math.round((loaded / total) * 100))
|
||||
}
|
||||
return new Blob(chunks)
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
|
||||
}
|
||||
|
||||
// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
|
||||
@@ -208,22 +326,6 @@ async function doCreateFolder() {
|
||||
}
|
||||
|
||||
// ---- 行操作 ----
|
||||
async function download(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 1,
|
||||
})
|
||||
parPanel.value?.refresh()
|
||||
window.open(par.fullUrl, '_blank')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '生成下载链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeObject(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
@@ -248,7 +350,7 @@ async function restore(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 临时链接签发 ----
|
||||
// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
|
||||
const showPar = ref(false)
|
||||
const parBusy = ref(false)
|
||||
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
|
||||
@@ -257,6 +359,7 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
|
||||
expiresHours: 24,
|
||||
})
|
||||
const parUrl = ref('')
|
||||
const parIsBucket = computed(() => !parForm.objectName)
|
||||
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
|
||||
const QUICK_HOURS = [
|
||||
{ label: '1 小时', value: 1 },
|
||||
@@ -267,7 +370,11 @@ const QUICK_HOURS = [
|
||||
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
|
||||
|
||||
function openParModal(objectName: string) {
|
||||
Object.assign(parForm, { objectName, accessType: 'ObjectRead', expiresHours: 24 })
|
||||
Object.assign(parForm, {
|
||||
objectName,
|
||||
accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
|
||||
expiresHours: 24,
|
||||
})
|
||||
parUrl.value = ''
|
||||
showPar.value = true
|
||||
}
|
||||
@@ -437,7 +544,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, onClick: () => download(o.name) },
|
||||
{ size: 'tiny', quaternary: true, onClick: () => download(o.name, o.size) },
|
||||
{ default: () => '下载' },
|
||||
),
|
||||
h(
|
||||
@@ -465,51 +572,111 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
|
||||
<!-- hero:与租户/VCN 详情同构——返回链 + 标题行(徽标/操作)+ meta 栅格(移动可折叠) -->
|
||||
<div class="panel px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-2 inline-flex cursor-pointer items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← 返回存储桶列表
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title min-w-0 truncate">{{ bucket }}</h1>
|
||||
<StatusBadge
|
||||
v-if="info"
|
||||
:kind="info.visibility === 'ObjectRead' ? 'prov' : 'check'"
|
||||
:label="info.visibility === 'ObjectRead' ? '公共读' : '私有'"
|
||||
:dot="false"
|
||||
/>
|
||||
<StatusBadge v-if="info?.versioningOn" kind="run" label="版本控制" :dot="false" />
|
||||
<div class="flex-1" />
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="small" @click="openParModal('')">分享桶</NButton>
|
||||
<NButton size="small" @click="emit('settings')">桶设置</NButton>
|
||||
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
|
||||
<span class="cursor-pointer hover:text-ink" @click="emit('back')">存储桶</span>
|
||||
<div class="text-xs text-ink-3">对象数</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info?.approximateCount ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">已用容量</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info ? fmtBytes(info.approximateSize) : '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">存储层</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.storageTier ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">创建时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="objects" tab="对象" />
|
||||
<NTabPane name="par" tab="分享链接" />
|
||||
</NTabs>
|
||||
|
||||
<div v-show="tab === 'objects'" class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3 max-md:px-3.5">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
|
||||
<span
|
||||
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
|
||||
:title="bucket"
|
||||
@click="enterFolder('')"
|
||||
>
|
||||
{{ bucket }}
|
||||
</span>
|
||||
<template v-if="foldedOptions.length">
|
||||
<span>/</span>
|
||||
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
|
||||
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
|
||||
</NDropdown>
|
||||
</template>
|
||||
<template v-for="c in visibleCrumbs" :key="c.prefix">
|
||||
<span>/</span>
|
||||
<span
|
||||
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
|
||||
:title="bucket"
|
||||
@click="enterFolder('')"
|
||||
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
|
||||
:title="c.label"
|
||||
@click="enterFolder(c.prefix)"
|
||||
>
|
||||
{{ bucket }}
|
||||
{{ c.label }}
|
||||
</span>
|
||||
<template v-if="foldedOptions.length">
|
||||
<span>/</span>
|
||||
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
|
||||
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
|
||||
</NDropdown>
|
||||
</template>
|
||||
<template v-for="c in visibleCrumbs" :key="c.prefix">
|
||||
<span>/</span>
|
||||
<span
|
||||
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
|
||||
:title="c.label"
|
||||
@click="enterFolder(c.prefix)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
前缀模式展示,文件夹为虚拟层级 · 点击对象名预览 / 查看详情 · 上传经 PAR 直传不经面板中转
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NInput
|
||||
v-model:value="filterInput"
|
||||
size="small"
|
||||
placeholder="搜索本层对象…"
|
||||
clearable
|
||||
class="!w-52"
|
||||
class="!w-52 max-md:!w-auto max-md:flex-1"
|
||||
/>
|
||||
<NButton size="small" @click="showNewFolder = true">新建文件夹</NButton>
|
||||
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,23 +701,6 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="uploads.length" class="border-b border-line-soft px-4.5 py-2">
|
||||
<div v-for="t in uploads" :key="t.name" class="flex items-center gap-3 py-1 text-[12.5px]">
|
||||
<span class="mono max-w-60 truncate">{{ t.name }}</span>
|
||||
<NProgress
|
||||
v-if="!t.error"
|
||||
type="line"
|
||||
:percentage="t.percent"
|
||||
:height="4"
|
||||
:show-indicator="false"
|
||||
class="max-w-40"
|
||||
/>
|
||||
<span v-if="t.error" class="text-err">{{ t.error }}</span>
|
||||
<span v-else class="mono text-ink-3">{{ t.percent }}%</span>
|
||||
<span class="cursor-pointer text-ink-3 hover:text-ink" @click="dismissUpload(t)">取消</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedKeys"
|
||||
:columns="columns"
|
||||
@@ -560,10 +710,10 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
:row-key="(r: Row) => r.key"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
class="flex items-center justify-between gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · Archive 层需恢复后才可下载</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
|
||||
<span class="flex flex-none items-center gap-1.5">
|
||||
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
|
||||
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
|
||||
下一页
|
||||
@@ -572,7 +722,97 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ParPanel ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
|
||||
<ParPanel v-show="tab === 'par'" ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
|
||||
|
||||
<!-- 传输列表浮层:上传主色 / 下载 info 色,失败红字可重试,可收起 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="transfers.length"
|
||||
class="fixed right-5 bottom-5 z-40 w-80 max-w-[calc(100vw-24px)] max-md:right-3 max-md:bottom-[calc(64px_+_env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-card shadow-[0_10px_32px_-8px_rgba(0,0,0,0.22)]">
|
||||
<div
|
||||
class="flex items-center gap-2 bg-wash px-3.5 py-2 text-[12.5px]"
|
||||
:class="transfersCollapsed ? '' : 'border-b border-line-soft'"
|
||||
>
|
||||
<span class="font-medium">传输列表</span>
|
||||
<span v-if="activeCount" class="text-ink-3">{{ activeCount }} 进行中</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
v-if="doneCount"
|
||||
type="button"
|
||||
class="cursor-pointer rounded px-1.5 py-0.5 text-xs text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
@click="clearDone"
|
||||
>
|
||||
清除已完成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded p-1 text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
:title="transfersCollapsed ? '展开' : '收起'"
|
||||
@click="transfersCollapsed = !transfersCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform"
|
||||
:class="transfersCollapsed ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!transfersCollapsed" class="max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="t in transfers"
|
||||
:key="t.id"
|
||||
class="flex items-center gap-2.5 border-b border-line-soft px-3.5 py-2 text-[12.5px] last:border-b-0"
|
||||
>
|
||||
<span
|
||||
class="flex-none font-semibold"
|
||||
:class="t.status === 'error' ? 'text-err' : t.kind === 'up' ? 'text-accent' : 'text-info'"
|
||||
>
|
||||
{{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="mono truncate">{{ t.name }}</span>
|
||||
<span v-if="t.status === 'active'" class="mono flex-none text-xs text-ink-3">
|
||||
{{ t.percent }}%
|
||||
</span>
|
||||
<span v-else-if="t.status === 'done'" class="flex-none text-xs text-ok">完成</span>
|
||||
</div>
|
||||
<div v-if="t.status === 'active'" class="mt-1 h-1 overflow-hidden rounded-full bg-line-soft">
|
||||
<span
|
||||
class="block h-full rounded-full"
|
||||
:class="t.kind === 'up' ? 'bg-accent' : 'bg-info'"
|
||||
:style="{ width: `${t.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="t.status === 'error'" class="mt-0.5 flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 truncate text-err" :title="t.error">{{ t.error }}</span>
|
||||
<span class="flex-none cursor-pointer font-medium text-accent" @click="retryTask(t)">
|
||||
重试
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="t.note" class="mt-0.5 text-xs text-ink-3">{{ t.note }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer text-ink-3 hover:text-ink"
|
||||
:title="t.status === 'active' ? '取消' : '移除'"
|
||||
@click="removeTask(t)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ObjectViewerModal
|
||||
v-model:show="showViewer"
|
||||
@@ -580,9 +820,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
:region="region"
|
||||
:bucket="bucket"
|
||||
:object="viewerObject"
|
||||
:versioning-on="versioningOn"
|
||||
:visibility="visibility"
|
||||
:namespace="namespace"
|
||||
:versioning-on="info?.versioningOn"
|
||||
:visibility="info?.visibility"
|
||||
:namespace="info?.namespace"
|
||||
@download="download"
|
||||
@share="openParModal"
|
||||
@changed="() => void result.run({ silent: true })"
|
||||
@@ -605,7 +845,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
|
||||
<FormModal
|
||||
:show="showPar"
|
||||
title="分享对象"
|
||||
:title="parIsBucket ? '分享桶' : '分享对象'"
|
||||
:width="460"
|
||||
:submitting="parBusy"
|
||||
:submit-disabled="!!parUrl || !parForm.expiresHours"
|
||||
@@ -613,13 +853,13 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
@update:show="showPar = $event"
|
||||
@submit="doCreatePar"
|
||||
>
|
||||
<FormField label="对象">
|
||||
<span class="mono text-[12.5px]">{{ parForm.objectName || '(桶级)' }}</span>
|
||||
<FormField :label="parIsBucket ? '范围' : '对象'">
|
||||
<span class="mono text-[12.5px]">{{ parForm.objectName || '整桶对象(桶级链接)' }}</span>
|
||||
</FormField>
|
||||
<FormField label="权限">
|
||||
<NRadioGroup v-model:value="parForm.accessType">
|
||||
<NRadio value="ObjectRead">只读</NRadio>
|
||||
<NRadio value="ObjectReadWrite">读写</NRadio>
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectRead' : 'ObjectRead'">只读</NRadio>
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
|
||||
</NRadioGroup>
|
||||
</FormField>
|
||||
<FormField label="有效期" hint="OCI 上限 30 天(720 小时)">
|
||||
|
||||
@@ -19,6 +19,7 @@ import TextEditPane from '@/components/objectstorage/viewer/TextEditPane.vue'
|
||||
import TextPreviewPane from '@/components/objectstorage/viewer/TextPreviewPane.vue'
|
||||
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
|
||||
@@ -48,6 +49,14 @@ const emit = defineEmits<{
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** 移动端全屏(复用 main.css 的 .form-modal-mobile 全局全屏规则) */
|
||||
const isMobile = useIsMobile()
|
||||
const modalStyle = computed(() =>
|
||||
isMobile.value
|
||||
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
|
||||
: { width: '1080px', maxWidth: 'calc(100vw - 24px)' },
|
||||
)
|
||||
|
||||
/** 文本预览/编辑体积上限;更大的文本请下载查看 */
|
||||
const TEXT_MAX = 1024 * 1024
|
||||
/** 图片与 office/pdf 的中转上限(与后端 20MB 限制一致) */
|
||||
@@ -346,7 +355,8 @@ const kvRows = computed(() => {
|
||||
:show="show"
|
||||
preset="card"
|
||||
:mask-closable="mode !== 'edit'"
|
||||
:style="{ width: '1080px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="isMobile ? 'form-modal-mobile' : ''"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template #header>
|
||||
@@ -362,10 +372,10 @@ const kvRows = computed(() => {
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<!-- 左:内容区 -->
|
||||
<div class="flex min-h-0 flex-col gap-4 md:flex-row max-md:flex-1 max-md:overflow-y-auto">
|
||||
<!-- 左:内容区(移动端全屏形态下压缩预览高度,信息与操作随外层滚动) -->
|
||||
<div
|
||||
class="flex h-[65vh] min-w-0 flex-1 flex-col rounded-lg border border-line-soft bg-wash/50 p-3"
|
||||
class="flex h-[65vh] min-w-0 flex-1 flex-col rounded-lg border border-line-soft bg-wash/50 p-3 max-md:h-auto max-md:min-h-[45dvh] max-md:flex-none"
|
||||
>
|
||||
<div
|
||||
v-if="detail.loading.value || loading"
|
||||
|
||||
@@ -190,6 +190,7 @@ const columns = computed<DataTableColumns<Par>>(() => [
|
||||
:loading="pars.loading.value"
|
||||
:pagination="false"
|
||||
:scroll-x="740"
|
||||
:max-height="440"
|
||||
:row-key="(r: Par) => r.id"
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
NButton,
|
||||
NDataTable,
|
||||
NInput,
|
||||
NModal,
|
||||
NRadioButton,
|
||||
NRadioGroup,
|
||||
type DataTableColumns,
|
||||
@@ -22,6 +21,7 @@ import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import AppInputNumber from '@/components/AppInputNumber.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import ProxyTenantsModal from '@/components/proxy/ProxyTenantsModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
@@ -279,7 +279,7 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
:row-key="(r: ProxyInfo) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
支持 SOCKS5 / HTTP / HTTPS;密码 AES 加密存储、不回显明文;地区与状态为经代理实测的出口探测结果(ip-api.com),创建后自动检测,探测失败即视为异常,可点「重测」复查;被租户关联的代理不可删除,请先解除关联
|
||||
支持 SOCKS5 / HTTP / HTTPS;被租户关联的代理须先解除关联才可删除
|
||||
</FootNote>
|
||||
|
||||
<ProxyTenantsModal
|
||||
@@ -288,11 +288,12 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
@saved="void proxies.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<NModal
|
||||
<FormModal
|
||||
v-model:show="showForm"
|
||||
preset="card"
|
||||
:title="editing ? '编辑代理' : '新增代理'"
|
||||
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
submit-text="保存"
|
||||
:submitting="saving"
|
||||
@submit="save"
|
||||
>
|
||||
<FormField label="类型">
|
||||
<NRadioGroup v-model:value="form.type" class="w-full" size="small">
|
||||
@@ -334,7 +335,7 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
<div class="mb-3.5 flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
|
||||
<div class="flex items-start gap-2.5 rounded-lg border border-ok/30 bg-ok/10 px-3 py-2.5">
|
||||
<svg
|
||||
class="mt-0.5 h-[15px] w-[15px] flex-none text-ok"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -352,17 +353,15 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="saving" @click="save">保存</NButton>
|
||||
<NButton size="small" quaternary @click="showForm = false">取消</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</FormModal>
|
||||
|
||||
<NModal
|
||||
<FormModal
|
||||
v-model:show="showImport"
|
||||
preset="card"
|
||||
title="批量导入代理"
|
||||
:style="{ width: '560px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
submit-text="导入"
|
||||
:submitting="importing"
|
||||
:width="560"
|
||||
@submit="doImport"
|
||||
>
|
||||
<FormField label="代理列表">
|
||||
<NInput
|
||||
@@ -404,10 +403,6 @@ const columns: DataTableColumns<ProxyInfo> = [
|
||||
<span class="ml-auto flex-none text-[11.5px] text-ink-2">{{ f.reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<NButton size="small" type="primary" :loading="importing" @click="doImport">导入</NButton>
|
||||
<NButton size="small" quaternary @click="showImport = false">关闭</NButton>
|
||||
</div>
|
||||
</NModal>
|
||||
</FormModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setProxyTenants } from '@/api/proxies'
|
||||
import FormField from '@/components/FormField.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { ProxyInfo } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -16,6 +17,7 @@ const props = defineProps<{ show: boolean; proxy: ProxyInfo | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; saved: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(480)
|
||||
const configs = useAsync(listConfigs, false)
|
||||
const selected = ref<number[]>([])
|
||||
const saving = ref(false)
|
||||
@@ -66,7 +68,8 @@ async function save() {
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`关联租户 · ${proxy?.name ?? ''}`"
|
||||
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<FormField label="关联租户" hint="选中即关联,取消选中即解除;关联后租户全部 SDK 请求经此代理出站">
|
||||
|
||||
@@ -23,6 +23,7 @@ import FormField from '@/components/FormField.vue'
|
||||
import FormModal from '@/components/FormModal.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { darkTokens, tokens } from '@/theme/tokens'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -31,6 +32,8 @@ import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
/** TOTP 启用引导弹窗的移动端全屏形态 */
|
||||
const { modalStyle, modalClass } = useMobileModal(360)
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
@@ -426,7 +429,7 @@ async function deleteProvider(p: ProviderType) {
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">两步验证(TOTP)</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
登录需额外输入验证器动态码;兼容 Google Authenticator / 1Password 等
|
||||
登录需额外输入验证器动态码
|
||||
</div>
|
||||
</div>
|
||||
<NSpin v-if="totp.loading.value" size="small" />
|
||||
@@ -601,9 +604,8 @@ async function deleteProvider(p: ProviderType) {
|
||||
尚未配置任何登录方式;点击右上「新增登录方式」配置 GitHub 或 OIDC
|
||||
</div>
|
||||
<FootNote>
|
||||
在 IdP / GitHub 应用中填写的授权回调地址(Redirect URI):{{ callbackBase
|
||||
}}/api/v1/auth/oauth/<github|oidc>/callback;secret 加密存储不回显 ·
|
||||
需先在「网络与地址」保存面板地址
|
||||
在 IdP / GitHub 应用中填写的授权回调地址:
|
||||
{{ callbackBase}}/api/v1/auth/oauth/<github|oidc>/callback
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
@@ -747,7 +749,8 @@ async function deleteProvider(p: ProviderType) {
|
||||
preset="card"
|
||||
title="启用两步验证"
|
||||
closable
|
||||
:style="{ width: '360px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<div v-if="totpPending" class="flex flex-col items-center gap-3">
|
||||
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
|
||||
|
||||
@@ -153,7 +153,7 @@ function onBlacklistChanged() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
修改即时生效,无需重启 · 仅作用于 Responses 直通面;Chat / Messages 已有断流自动重做兜底
|
||||
仅作用于 Responses 直通面;Chat / Messages 已有断流自动重做兜底
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,8 +162,7 @@ function onBlacklistChanged() {
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">grok 服务端搜索工具</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
仅对 <span class="mono">xai.</span> 前缀模型的对话请求生效;请求 tools
|
||||
已包含同名工具时保持原样,不覆盖参数
|
||||
仅对 <span class="mono">xai.</span> 前缀模型的对话请求生效
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4.5">
|
||||
@@ -198,7 +197,7 @@ function onBlacklistChanged() {
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">过滤弃用模型</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
开启后,已宣布弃用(即使未退役)的模型从模型列表与路由中排除;关闭恢复展示
|
||||
开启后,已宣布弃用(即使未退役)的模型从模型列表与路由中排除
|
||||
</div>
|
||||
</div>
|
||||
<NSwitch
|
||||
@@ -212,7 +211,7 @@ function onBlacklistChanged() {
|
||||
<div class="min-w-0">
|
||||
<div class="text-[13px] font-medium">模型黑名单</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
命中的模型从模型列表与路由排除,重新同步渠道后移出才恢复
|
||||
命中的模型从模型列表与路由排除
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="tiny" quaternary type="primary" @click="showAdd = true">+ 添加模型</NButton>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { addAiBlacklist, listAiModelCatalog } from '@/api/aigateway'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { AiCatalogModel } from '@/types/api'
|
||||
|
||||
@@ -10,6 +11,7 @@ const props = defineProps<{ show: boolean; blacklisted: string[] }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; changed: [] }>()
|
||||
|
||||
const toast = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const rows = ref<AiCatalogModel[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -78,7 +80,8 @@ async function doBan(name: string) {
|
||||
<NModal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template #header>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NButton, NInput, NModal } from 'naive-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { testNotifyTemplate, updateNotifyTemplate } from '@/api/settings'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { NotifyTemplateItem } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
@@ -20,6 +21,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(600)
|
||||
const draft = ref('')
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
@@ -104,7 +106,8 @@ async function sendTest() {
|
||||
v-model:show="visible"
|
||||
preset="card"
|
||||
closable
|
||||
:style="{ width: '600px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-1">
|
||||
|
||||
@@ -11,11 +11,15 @@ import JsonBlock from '@/components/JsonBlock.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { eventLabel } from '@/composables/useEventLabel'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { AuditEvent } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{ cfgId: number }>()
|
||||
const message = useToast()
|
||||
const isMobile = useIsMobile()
|
||||
const { modalStyle, modalClass } = useMobileModal(720)
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
@@ -67,6 +71,10 @@ const pagination = reactive({
|
||||
pageSize: 20,
|
||||
showSizePicker: true,
|
||||
pageSizes: [20, 50, 100],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
/** 状态行:加载中/尽头给文案;停驻且还有更早数据时给「加载更早」按钮,
|
||||
* 自动补批封顶后由用户显式接力,不会无人值守扫完整个保留期 */
|
||||
prefix: () => {
|
||||
@@ -283,7 +291,6 @@ const columns: DataTableColumns<AuditEvent> = [
|
||||
@clear="submitSearch()"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
|
||||
</div>
|
||||
|
||||
<EmptyCard v-if="error" title="审计日志加载失败" :note="error" />
|
||||
@@ -297,14 +304,11 @@ const columns: DataTableColumns<AuditEvent> = [
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<FootNote>
|
||||
OCI Audit 免费且默认开启,事件保留 365 天,无需在云端做任何配置 ·
|
||||
实时查询不入库,事件通常延迟数分钟可见 ·
|
||||
已默认过滤遥测噪声(SummarizeMetricsData)与内网地址发起的服务互调 ·
|
||||
从最新事件起每批约 200 条,翻到末页自动向更早加载,空档期自动扩窗回溯 ·
|
||||
搜索匹配事件 / 资源 / 操作者等可见字段(不区分大小写,支持 * 通配),命中较少时自动补两批后停驻,点「加载更早」继续回溯 · 点击行查看完整详情
|
||||
事件通常延迟数分钟可见 ·
|
||||
从最新事件起每批约 200 条,翻到末页自动向更早加载,命中较少时自动补两批后停驻
|
||||
</FootNote>
|
||||
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
|
||||
<NModal v-model:show="showDetail" preset="card" closable :style="modalStyle" :class="modalClass">
|
||||
<template #header>
|
||||
<DetailHero
|
||||
v-if="detail"
|
||||
|
||||
@@ -178,7 +178,7 @@ function addExemption() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">SAML 身份提供商</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton size="small" @click="doDownloadMetadata">下载 SP 元数据</NButton>
|
||||
@@ -193,13 +193,12 @@ function addExemption() {
|
||||
:row-key="(r: IdentityProvider) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
推荐流程:下载 SP SAML 元数据导入 IdP 侧 → 创建 IdP → 激活(自动显示到登录页)→ 创建免 MFA
|
||||
规则;停用与删除会先将 IdP 移出登录页
|
||||
流程:下载 SP 元数据导入 IdP 侧 → 创建 → 激活上登录页 → 建免 MFA 规则;停用 / 删除自动移出登录页
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">Sign-on 策略(免 MFA 规则)</div>
|
||||
<NButton size="small" @click="addExemption">为已启用 IdP 创建免 MFA 规则</NButton>
|
||||
</div>
|
||||
|
||||
@@ -8,12 +8,14 @@ import { useToast } from '@/composables/useToast'
|
||||
import { invoiceDate, invoiceStatusMeta } from '@/components/tenant/invoiceStatus'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtMoney } from '@/composables/useFormat'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { Invoice } from '@/types/api'
|
||||
|
||||
/** 发票明细弹窗:费用行 + 合计 + 付款/PDF 入口 */
|
||||
const props = defineProps<{ show: boolean; cfgId: number; invoice: Invoice | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean]; pay: [Invoice] }>()
|
||||
const message = useToast()
|
||||
const { modalStyle, modalClass } = useMobileModal(640)
|
||||
|
||||
const lines = useAsync(async () => {
|
||||
const inv = props.invoice
|
||||
@@ -51,7 +53,8 @@ async function onPdf() {
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="invoice ? `发票 ${invoice.number || invoice.id}` : '发票明细'"
|
||||
:style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<template v-if="invoice">
|
||||
|
||||
@@ -4,11 +4,13 @@ import { ref, watch } from 'vue'
|
||||
|
||||
import { fetchInvoicePdf, saveInvoicePdf } from '@/api/billing'
|
||||
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
|
||||
import { useMobileModal } from '@/composables/useMobileModal'
|
||||
import type { Invoice } from '@/types/api'
|
||||
|
||||
/** 发票 PDF 预览弹窗:面板代理拉取后 vue-office 渲染,预览框内可下载 */
|
||||
const props = defineProps<{ show: boolean; cfgId: number; invoice: Invoice | null }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||||
const { modalStyle, modalClass } = useMobileModal(860)
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@@ -49,10 +51,12 @@ function download() {
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="invoice ? `发票 ${invoice.number || invoice.id} · PDF` : 'PDF 预览'"
|
||||
:style="{ width: '860px', maxWidth: 'calc(100vw - 24px)' }"
|
||||
:style="modalStyle"
|
||||
:class="modalClass"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<div class="h-[70vh] overflow-auto rounded-lg border border-line-soft bg-wash/50">
|
||||
<!-- 全屏形态下预览区吃满内容区剩余高度(内容区为 flex column) -->
|
||||
<div class="h-[70vh] overflow-auto rounded-lg border border-line-soft bg-wash/50 max-md:h-auto max-md:min-h-0 max-md:flex-1">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
|
||||
@@ -296,8 +296,6 @@ const rowProps = (row: Invoice) => ({
|
||||
<div class="flex items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">发票</div>
|
||||
<div class="text-xs text-ink-3">已加载 {{ list.length }} 张,点击行查看费用明细</div>
|
||||
<div class="flex-1" />
|
||||
<NButton size="tiny" quaternary @click="init">刷新</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
|
||||
@@ -312,7 +312,7 @@ function policyDesc(p: PasswordPolicy): string {
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<!-- 密码策略与身份 -->
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">密码策略与身份</div>
|
||||
<div class="text-[12.5px] text-ink-3">Default 身份域 · IAM 规则</div>
|
||||
</div>
|
||||
|
||||
@@ -75,12 +75,12 @@ function subtitle(m: PaymentMethod): string {
|
||||
合约/伙伴结算账号的付款安排请联系 Oracle 销售或查看合同条款
|
||||
</div>
|
||||
<div v-else class="py-8 text-center text-[12.5px] text-ink-3">
|
||||
未登记付款方式(Free Tier 常见);添加卡片请前往 OCI Billing Center
|
||||
未登记付款方式
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-lg bg-wash px-3 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
添加或更换卡片需经 OCI 托管付款页完成,面板不经手卡号;请在 OCI 控制台「Billing &
|
||||
Cost Management → Payment Methods」操作,完成后回本页刷新即可。
|
||||
加换卡片请在 OCI 控制台「Billing &
|
||||
Cost Management → Payment Methods」操作。
|
||||
</div>
|
||||
</NModal>
|
||||
</template>
|
||||
|
||||
@@ -12,12 +12,15 @@ import { computed, h, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { listCachedRegions } from '@/api/configs'
|
||||
import { listLimitServices, listLimits } from '@/api/tenant'
|
||||
import FilterBar from '@/components/FilterBar.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { regionCity, shortAd } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import type { LimitItem } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ cfgId: number }>()
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const service = ref('compute')
|
||||
const name = ref('')
|
||||
@@ -90,6 +93,10 @@ const pagination = reactive({
|
||||
pageSize: 10,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50],
|
||||
// 移动端页码槽位放不下会被裁切,切 simple 形态(‹ n/总页 ›)
|
||||
get simple() {
|
||||
return isMobile.value
|
||||
},
|
||||
onUpdatePage: (p: number) => {
|
||||
pagination.page = p
|
||||
},
|
||||
@@ -184,59 +191,59 @@ const columns = computed(() => (withAvail.value ? [...baseColumns, ...availColum
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center gap-2 px-4.5 py-3">
|
||||
<div class="flex flex-col gap-2.5 px-4.5 py-3">
|
||||
<div class="text-sm font-semibold">服务配额</div>
|
||||
<div class="flex-1" />
|
||||
<NInputGroup class="!w-60 max-md:!w-full">
|
||||
<NInputGroupLabel size="small">区域</NInputGroupLabel>
|
||||
<NSelect
|
||||
v-model:value="region"
|
||||
size="small"
|
||||
filterable
|
||||
:options="regionOptions"
|
||||
:loading="regions.loading.value"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-72 max-lg:!w-60 max-md:!w-full">
|
||||
<NInputGroupLabel size="small">服务</NInputGroupLabel>
|
||||
<NSelect
|
||||
v-model:value="service"
|
||||
size="small"
|
||||
filterable
|
||||
:options="serviceOptions"
|
||||
:loading="services.loading.value"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-52 max-lg:!w-44 max-md:!w-full">
|
||||
<NInputGroupLabel size="small">作用域</NInputGroupLabel>
|
||||
<NSelect v-model:value="scopeType" size="small" :options="scopeOptions" />
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-56 max-lg:!w-44 max-md:!w-full">
|
||||
<NInputGroupLabel size="small">配额名</NInputGroupLabel>
|
||||
<NInput
|
||||
v-model:value="name"
|
||||
size="small"
|
||||
placeholder="如 core-count"
|
||||
clearable
|
||||
@keyup.enter="limits.run()"
|
||||
@clear="limits.run()"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="hideZero" size="small">忽略 0 配额</NCheckbox>
|
||||
<NCheckbox v-model:checked="withAvail" size="small">查用量</NCheckbox>
|
||||
<FilterBar>
|
||||
<NInputGroup class="!w-60">
|
||||
<NInputGroupLabel size="small">区域</NInputGroupLabel>
|
||||
<NSelect
|
||||
v-model:value="region"
|
||||
size="small"
|
||||
filterable
|
||||
:options="regionOptions"
|
||||
:loading="regions.loading.value"
|
||||
:consistent-menu-width="false"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-72 max-lg:!w-60">
|
||||
<NInputGroupLabel size="small">服务</NInputGroupLabel>
|
||||
<NSelect
|
||||
v-model:value="service"
|
||||
size="small"
|
||||
filterable
|
||||
:options="serviceOptions"
|
||||
:loading="services.loading.value"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-56 max-lg:!w-48">
|
||||
<NInputGroupLabel size="small">作用域</NInputGroupLabel>
|
||||
<NSelect v-model:value="scopeType" size="small" :options="scopeOptions" />
|
||||
</NInputGroup>
|
||||
<NInputGroup class="!w-56 max-lg:!w-48">
|
||||
<NInputGroupLabel size="small">配额名</NInputGroupLabel>
|
||||
<NInput
|
||||
v-model:value="name"
|
||||
size="small"
|
||||
placeholder="如 core-count"
|
||||
clearable
|
||||
@keyup.enter="limits.run()"
|
||||
@clear="limits.run()"
|
||||
/>
|
||||
</NInputGroup>
|
||||
<NCheckbox v-model:checked="hideZero" size="small">忽略 0 配额</NCheckbox>
|
||||
<NCheckbox v-model:checked="withAvail" size="small">查用量</NCheckbox>
|
||||
</FilterBar>
|
||||
</div>
|
||||
<NDataTable
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:loading="limits.loading.value"
|
||||
:pagination="pagination"
|
||||
:scroll-x="withAvail ? 760 : 380"
|
||||
:scroll-x="withAvail ? 980 : 560"
|
||||
:row-key="(r: LimitItem) => r.name + (r.availabilityDomain ?? '')"
|
||||
/>
|
||||
<FootNote>
|
||||
勾选「查用量」后单次限 100 条,超出请用配额名 / 作用域过滤收窄,配合「忽略 0
|
||||
配额」(服务端过滤)可大幅释放名额;不支持用量查询的配额自动跳过并显示「无用量数据」
|
||||
「查用量」单次限 100 条,用筛选与「忽略 0 配额」收窄;不支持的配额显示「无用量数据」
|
||||
</FootNote>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -202,7 +202,7 @@ function subLabel(d: SubscriptionDetail): string {
|
||||
</div>
|
||||
|
||||
<div v-for="d in subs.data.value" v-else :key="d.id" class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">云订阅</div>
|
||||
<div class="text-[12.5px] text-ink-3">{{ subLabel(d) }} · 账户类别数据来源</div>
|
||||
</div>
|
||||
@@ -258,7 +258,7 @@ function subLabel(d: SubscriptionDetail): string {
|
||||
</div>
|
||||
|
||||
<div class="panel min-w-0">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">区域订阅</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
已订阅 {{ subscribedCount }} ·
|
||||
|
||||
@@ -158,7 +158,7 @@ const columns: DataTableColumns<IamUser> = [
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">IAM 用户</div>
|
||||
<NButton size="small" type="primary" @click="openCreate">添加用户</NButton>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,21 @@ const exact: Record<string, string> = {
|
||||
'POST /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '添加 IPv6 地址',
|
||||
'DELETE /api/v1/oci-configs/:id/instances/:instanceId/ipv6-addresses': '删除 IPv6 地址',
|
||||
'POST /api/v1/oci-configs/:id/instances/:instanceId/replace-boot-volume': '更换引导卷',
|
||||
'PUT /api/v1/oci-configs/:id/buckets/:bucket/objects/content': '保存对象内容',
|
||||
'POST /api/v1/oci-configs/:id/buckets/:bucket/objects/rename': '重命名对象',
|
||||
'POST /api/v1/oci-configs/:id/buckets/:bucket/objects/restore': '恢复归档对象',
|
||||
'DELETE /api/v1/oci-configs/:id/reserved-ips/:publicIpId': '释放预留公网 IP',
|
||||
'POST /api/v1/oci-configs/:id/vnics/:vnicId/change-public-ip': '更换公网 IP',
|
||||
'POST /api/v1/oci-configs/:id/vnics/:vnicId/ipv6-addresses': '添加 IPv6 地址',
|
||||
'POST /api/v1/oci-configs/:id/instances/:instanceId/vnics': '附加 VNIC',
|
||||
'DELETE /api/v1/oci-configs/:id/vnic-attachments/:attachmentId': '分离 VNIC',
|
||||
'POST /api/v1/oci-configs/:id/instances/:instanceId/volume-attachments': '挂载块卷',
|
||||
'DELETE /api/v1/oci-configs/:id/volume-attachments/:attachmentId': '分离块卷',
|
||||
'POST /api/v1/oci-configs/:id/instances/:instanceId/boot-volume-attachments': '挂载引导卷',
|
||||
'DELETE /api/v1/oci-configs/:id/boot-volume-attachments/:attachmentId': '分离引导卷',
|
||||
'POST /api/v1/oci-configs/:id/invoices/:internalId/pay': '支付账单',
|
||||
'POST /api/v1/oci-configs/:id/vcns/:vcnId/enable-ipv6': '启用 VCN IPv6',
|
||||
'POST /api/v1/oci-configs/:id/identity-providers/:idpId/activate': '激活身份提供方',
|
||||
'POST /api/v1/oci-configs/:id/users': '创建租户用户',
|
||||
'PUT /api/v1/oci-configs/:id/users/:userId': '修改租户用户',
|
||||
'DELETE /api/v1/oci-configs/:id/users/:userId': '删除租户用户',
|
||||
@@ -85,6 +100,13 @@ const nouns: Record<string, string> = {
|
||||
'identity-providers': '身份提供方',
|
||||
'sign-on-exemptions': 'MFA 豁免',
|
||||
proxies: '代理',
|
||||
buckets: '桶',
|
||||
objects: '对象',
|
||||
pars: '分享链接',
|
||||
'reserved-ips': '预留公网 IP',
|
||||
vnics: 'VNIC',
|
||||
'vnic-attachments': 'VNIC 挂载',
|
||||
invoices: '账单',
|
||||
}
|
||||
|
||||
const verbs: Record<string, string> = { POST: '创建', PUT: '修改', DELETE: '删除' }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
/** 移动/桌面切换线与 CSS 断点同源:≤767px 为移动形态(Tailwind md 之下) */
|
||||
const QUERY = '(max-width: 767px)'
|
||||
|
||||
const isMobile = ref(false)
|
||||
let started = false
|
||||
|
||||
/** 模块级单例监听:任意组件首次调用时启动,全应用共享一个 matchMedia */
|
||||
function ensureWatcher() {
|
||||
if (started || typeof window === 'undefined') return
|
||||
started = true
|
||||
const mq = window.matchMedia(QUERY)
|
||||
isMobile.value = mq.matches
|
||||
mq.addEventListener('change', (e) => (isMobile.value = e.matches))
|
||||
}
|
||||
|
||||
/** 响应式返回「当前是否移动形态」;JS 层条件渲染(表格换卡片、弹窗全屏)用它 */
|
||||
export function useIsMobile() {
|
||||
ensureWatcher()
|
||||
return readonly(isMobile)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useIsMobile } from './useIsMobile'
|
||||
|
||||
/** 弹窗移动端全屏化:桌面定宽居中,移动端 100vw/100dvh 满屏。
|
||||
返回的 class 配合 main.css 的 .form-modal-mobile 布局规则,
|
||||
与 FormModal 同形态;直接用原生 NModal 的弹窗接入此组合式即可 */
|
||||
export function useMobileModal(width: number) {
|
||||
const isMobile = useIsMobile()
|
||||
const modalStyle = computed(() =>
|
||||
isMobile.value
|
||||
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
|
||||
: { width: `${width}px`, maxWidth: 'calc(100vw - 24px)' },
|
||||
)
|
||||
const modalClass = computed(() => (isMobile.value ? 'form-modal-mobile' : ''))
|
||||
return { modalStyle, modalClass }
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { NPopselect } from 'naive-ui'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import MobileTabBar from './MobileTabBar.vue'
|
||||
import MoreSheet from './MoreSheet.vue'
|
||||
import SidebarNav from './SidebarNav.vue'
|
||||
import { logout as logoutApi } from '@/api/auth'
|
||||
import SerialConsolePanel from '@/components/instance/SerialConsolePanel.vue'
|
||||
import TenantPicker from '@/components/TenantPicker.vue'
|
||||
import { regionCity } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -22,6 +25,10 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { alias } = useRegionAlias()
|
||||
|
||||
/** ≤767px:侧栏让位于底部导航 + 「更多」抽屉(设计稿定稿形态) */
|
||||
const isMobile = useIsMobile()
|
||||
const showMore = ref(false)
|
||||
|
||||
/** 胶囊内区域只显城市短名(友好名括号内文字),完整名留在菜单与悬浮提示 */
|
||||
const regionFull = computed(() => alias(scope.region))
|
||||
const regionShort = computed(() => regionCity(regionFull.value))
|
||||
@@ -59,14 +66,15 @@ async function logout() {
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen">
|
||||
<SidebarNav :collapsed="app.sidebarCollapsed" @logout="logout" />
|
||||
<SidebarNav v-if="!isMobile" :collapsed="app.sidebarCollapsed" @logout="logout" />
|
||||
|
||||
<main class="flex min-w-0 flex-1 flex-col">
|
||||
<header
|
||||
class="flex h-14 flex-none items-center justify-between gap-3 border-b border-line pr-6 pl-4"
|
||||
class="flex h-14 flex-none items-center justify-between gap-3 border-b border-line pr-6 pl-4 max-md:pr-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<button
|
||||
v-if="!isMobile"
|
||||
class="flex h-8 w-8 flex-none cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
|
||||
title="收起 / 展开侧栏"
|
||||
aria-label="收起或展开侧栏"
|
||||
@@ -147,7 +155,7 @@ async function logout() {
|
||||
>
|
||||
<path d="M3 21h18M5 21V7l7-4 7 4v14M9 21v-6h6v6" />
|
||||
</svg>
|
||||
<span class="max-w-[200px] truncate text-[13px] max-lg:max-w-[120px] max-md:hidden">
|
||||
<span class="max-w-[200px] truncate text-[13px] max-lg:max-w-[120px] max-md:max-w-[84px]">
|
||||
{{ scope.currentConfig?.alias ?? '选择租户' }}
|
||||
</span>
|
||||
<svg
|
||||
@@ -194,7 +202,7 @@ async function logout() {
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 0 20 15.3 15.3 0 0 1 0-20" />
|
||||
</svg>
|
||||
<span class="max-w-[160px] truncate text-[13px] max-md:hidden">
|
||||
<span class="max-w-[160px] truncate text-[13px] max-md:max-w-[72px]">
|
||||
{{ regionShort }}
|
||||
</span>
|
||||
<svg
|
||||
@@ -213,12 +221,20 @@ async function logout() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 内容区限宽居中,避免超宽屏下的空置感 -->
|
||||
<div class="mx-auto w-full max-w-[1560px]">
|
||||
<!-- 内容区限宽居中,避免超宽屏下的空置感;移动端为底栏预留高度 -->
|
||||
<div
|
||||
class="mx-auto w-full max-w-[1560px]"
|
||||
:style="isMobile ? { paddingBottom: 'calc(66px + env(safe-area-inset-bottom))' } : undefined"
|
||||
>
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<template v-if="isMobile">
|
||||
<MobileTabBar @more="showMore = true" />
|
||||
<MoreSheet v-model:show="showMore" />
|
||||
</template>
|
||||
|
||||
<!-- 串行控制台:全局挂载,切换路由不断开会话 -->
|
||||
<SerialConsolePanel
|
||||
v-model:show="consoleStore.show"
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { findNavItem, mobileMoreNames, mobileTabNames, navIcon } from './navItems'
|
||||
|
||||
/** 移动端底部导航:高频四项直达 + 「更多」唤起抽屉(设计稿 ①) */
|
||||
const emit = defineEmits<{ more: [] }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabs = mobileTabNames.map((name) => {
|
||||
const item = findNavItem(name)
|
||||
return { ...item, icon: navIcon(item.iconPaths, 'h-[22px] w-[22px] flex-none') }
|
||||
})
|
||||
|
||||
const moreIcon = navIcon(
|
||||
'<circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/>',
|
||||
'h-[22px] w-[22px] flex-none',
|
||||
)
|
||||
|
||||
const activeName = computed(() => {
|
||||
const current = String(route.name)
|
||||
return tabs.find((t) => t.activeNames.includes(current))?.name ?? ''
|
||||
})
|
||||
|
||||
/** 当前路由属「更多」内页面时高亮更多槽 */
|
||||
const moreActive = computed(() => {
|
||||
const current = String(route.name)
|
||||
return mobileMoreNames.some((n) => findNavItem(n).activeNames.includes(current))
|
||||
})
|
||||
|
||||
function go(name: string) {
|
||||
if (name !== String(route.name)) void router.push({ name })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
class="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-bg px-1 pt-1.5"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex">
|
||||
<button
|
||||
v-for="t in tabs"
|
||||
:key="t.name"
|
||||
class="flex min-h-[52px] flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md pb-1"
|
||||
:class="activeName === t.name ? 'text-accent' : 'text-ink-3'"
|
||||
@click="go(t.name)"
|
||||
>
|
||||
<component :is="t.icon" />
|
||||
<span class="text-[10px] font-medium">{{ t.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex min-h-[52px] flex-1 cursor-pointer flex-col items-center justify-center gap-0.5 rounded-md pb-1"
|
||||
:class="moreActive ? 'text-accent' : 'text-ink-3'"
|
||||
@click="emit('more')"
|
||||
>
|
||||
<component :is="moreIcon" />
|
||||
<span class="text-[10px] font-medium">更多</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { NDrawer } from 'naive-ui'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { findNavItem, mobileMoreNames, navIcon } from './navItems'
|
||||
|
||||
/** 「更多」底部抽屉:收纳低频七项(设计稿 ④) */
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ 'update:show': [boolean] }>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const items = mobileMoreNames.map((name) => {
|
||||
const item = findNavItem(name)
|
||||
return { ...item, icon: navIcon(item.iconPaths, 'h-[22px] w-[22px] flex-none') }
|
||||
})
|
||||
|
||||
function go(name: string) {
|
||||
emit('update:show', false)
|
||||
if (name !== String(route.name)) void router.push({ name })
|
||||
}
|
||||
|
||||
function isActive(activeNames: string[]): boolean {
|
||||
return props.show && activeNames.includes(String(route.name))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NDrawer
|
||||
:show="show"
|
||||
placement="bottom"
|
||||
height="auto"
|
||||
:auto-focus="false"
|
||||
@update:show="emit('update:show', $event)"
|
||||
>
|
||||
<div class="bg-bg px-4 pt-2" style="padding-bottom: calc(16px + env(safe-area-inset-bottom))">
|
||||
<div class="mx-auto mb-3 mt-1 h-1 w-9 rounded-full bg-ink/20"></div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="it in items"
|
||||
:key="it.name"
|
||||
class="flex cursor-pointer flex-col items-center gap-1.5 rounded-md border border-line bg-card py-3.5"
|
||||
:class="isActive(it.activeNames) ? 'text-accent' : 'text-ink-2'"
|
||||
@click="go(it.name)"
|
||||
>
|
||||
<component :is="it.icon" />
|
||||
<span class="text-[11.5px] text-ink">{{ it.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</NDrawer>
|
||||
</template>
|
||||
+17
-133
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { h, type Component } from 'vue'
|
||||
import { type Component } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
import AppLogo from '@/components/AppLogo.vue'
|
||||
import { navGroups, navIcon } from './navItems'
|
||||
|
||||
defineProps<{ collapsed: boolean }>()
|
||||
const emit = defineEmits<{ logout: [] }>()
|
||||
@@ -21,128 +22,17 @@ interface NavGroup {
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
function icon(paths: string): Component {
|
||||
return () =>
|
||||
h('svg', {
|
||||
class: 'h-4 w-4 flex-none',
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.5',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: paths,
|
||||
})
|
||||
}
|
||||
/** 条目定义共享自 navItems.ts(与移动端底栏 / 「更多」抽屉同源) */
|
||||
const groups: NavGroup[] = navGroups.map((g) => ({
|
||||
label: g.label,
|
||||
items: g.items.map((i) => ({
|
||||
name: i.name,
|
||||
label: i.label,
|
||||
activeNames: i.activeNames,
|
||||
icon: navIcon(i.iconPaths),
|
||||
})),
|
||||
}))
|
||||
|
||||
const groups: NavGroup[] = [
|
||||
{
|
||||
label: '概览',
|
||||
items: [
|
||||
{
|
||||
name: 'overview',
|
||||
label: '总览',
|
||||
activeNames: ['overview'],
|
||||
icon: icon(
|
||||
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '资源',
|
||||
items: [
|
||||
{
|
||||
name: 'tenants',
|
||||
label: '租户',
|
||||
activeNames: ['tenants', 'tenant-detail'],
|
||||
icon: icon(
|
||||
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'instances',
|
||||
label: '实例',
|
||||
activeNames: ['instances', 'instance-detail'],
|
||||
icon: icon(
|
||||
'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
label: '网络',
|
||||
activeNames: ['network', 'vcn-detail'],
|
||||
icon: icon(
|
||||
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'boot-volumes',
|
||||
label: '存储',
|
||||
activeNames: ['boot-volumes'],
|
||||
icon: icon(
|
||||
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'object-storage',
|
||||
label: '对象存储',
|
||||
activeNames: ['object-storage'],
|
||||
icon: icon(
|
||||
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/>',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '自动化',
|
||||
items: [
|
||||
{
|
||||
name: 'tasks',
|
||||
label: '任务',
|
||||
activeNames: ['tasks'],
|
||||
icon: icon('<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>'),
|
||||
},
|
||||
{
|
||||
name: 'ai-gateway',
|
||||
label: 'AI 网关',
|
||||
activeNames: ['ai-gateway'],
|
||||
icon: icon(
|
||||
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"/>',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '系统',
|
||||
items: [
|
||||
{
|
||||
name: 'proxies',
|
||||
label: '代理',
|
||||
activeNames: ['proxies'],
|
||||
icon: icon(
|
||||
'<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'logs',
|
||||
label: '日志',
|
||||
activeNames: ['logs'],
|
||||
icon: icon(
|
||||
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: '设置',
|
||||
activeNames: ['settings'],
|
||||
icon: icon(
|
||||
'<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(item: NavItem): boolean {
|
||||
return item.activeNames.includes(String(route.name))
|
||||
@@ -167,7 +57,7 @@ function itemClass(item: NavItem): string {
|
||||
:class="collapsed ? 'justify-center px-0!' : ''"
|
||||
>
|
||||
<AppLogo :size="24" class="flex-none" />
|
||||
<span v-if="!collapsed" class="font-serif text-lg font-semibold max-md:hidden">
|
||||
<span v-if="!collapsed" class="font-serif text-lg font-semibold">
|
||||
OCI Portal
|
||||
</span>
|
||||
</div>
|
||||
@@ -176,7 +66,7 @@ function itemClass(item: NavItem): string {
|
||||
<template v-for="group in groups" :key="group.label">
|
||||
<div
|
||||
v-if="!collapsed"
|
||||
class="px-2 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-wider whitespace-nowrap text-ink-3 max-md:hidden"
|
||||
class="px-2 pt-3.5 pb-1.5 text-[11px] font-semibold tracking-wider whitespace-nowrap text-ink-3"
|
||||
>
|
||||
{{ group.label }}
|
||||
</div>
|
||||
@@ -190,12 +80,11 @@ function itemClass(item: NavItem): string {
|
||||
<template #default="{ navigate }">
|
||||
<div
|
||||
:class="[itemClass(item), collapsed ? 'justify-center gap-0 px-0' : '']"
|
||||
class="max-md:justify-center max-md:gap-0 max-md:px-0"
|
||||
:title="item.label"
|
||||
@click="navigate"
|
||||
>
|
||||
<component :is="item.icon" />
|
||||
<span v-if="!collapsed" class="max-md:hidden">{{ item.label }}</span>
|
||||
<span v-if="!collapsed">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</RouterLink>
|
||||
@@ -212,13 +101,13 @@ function itemClass(item: NavItem): string {
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<div v-if="!collapsed" class="min-w-0 flex-1 max-md:hidden">
|
||||
<div v-if="!collapsed" class="min-w-0 flex-1">
|
||||
<div class="text-[13px] font-medium">admin</div>
|
||||
<div class="text-[11.5px] text-ink-3">管理员</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!collapsed"
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink max-md:hidden"
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-ink-2 hover:bg-wash hover:text-ink"
|
||||
title="退出登录"
|
||||
aria-label="退出登录"
|
||||
@click="emit('logout')"
|
||||
@@ -240,9 +129,4 @@ function itemClass(item: NavItem): string {
|
||||
.sb-aside.collapsed {
|
||||
width: 64px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.sb-aside {
|
||||
width: 64px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { h, type Component } from 'vue'
|
||||
|
||||
/** 侧栏 / 底栏 / 「更多」抽屉共享的导航条目定义(唯一来源) */
|
||||
|
||||
export interface NavItem {
|
||||
name: string
|
||||
label: string
|
||||
iconPaths: string
|
||||
activeNames: string[]
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
label: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
/** 细线风格图标工厂:paths 为 24 viewBox 下的 svg 内部标记 */
|
||||
export function navIcon(paths: string, cls = 'h-4 w-4 flex-none'): Component {
|
||||
return () =>
|
||||
h('svg', {
|
||||
class: cls,
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '1.5',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: paths,
|
||||
})
|
||||
}
|
||||
|
||||
export const navGroups: NavGroup[] = [
|
||||
{
|
||||
label: '概览',
|
||||
items: [
|
||||
{
|
||||
name: 'overview',
|
||||
label: '总览',
|
||||
activeNames: ['overview'],
|
||||
iconPaths:
|
||||
'<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '资源',
|
||||
items: [
|
||||
{
|
||||
name: 'tenants',
|
||||
label: '租户',
|
||||
activeNames: ['tenants', 'tenant-detail'],
|
||||
iconPaths:
|
||||
'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
},
|
||||
{
|
||||
name: 'instances',
|
||||
label: '实例',
|
||||
activeNames: ['instances', 'instance-detail'],
|
||||
iconPaths:
|
||||
'<rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>',
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
label: '网络',
|
||||
activeNames: ['network', 'vcn-detail'],
|
||||
iconPaths:
|
||||
'<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
|
||||
},
|
||||
{
|
||||
name: 'boot-volumes',
|
||||
label: '存储',
|
||||
activeNames: ['boot-volumes'],
|
||||
iconPaths:
|
||||
'<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/>',
|
||||
},
|
||||
{
|
||||
name: 'object-storage',
|
||||
label: '对象存储',
|
||||
activeNames: ['object-storage'],
|
||||
iconPaths:
|
||||
'<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.3 7 12 12l8.7-5"/><path d="M12 22V12"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '自动化',
|
||||
items: [
|
||||
{
|
||||
name: 'tasks',
|
||||
label: '任务',
|
||||
activeNames: ['tasks'],
|
||||
iconPaths: '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
},
|
||||
{
|
||||
name: 'ai-gateway',
|
||||
label: 'AI 网关',
|
||||
activeNames: ['ai-gateway'],
|
||||
iconPaths:
|
||||
'<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M18.5 15.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '系统',
|
||||
items: [
|
||||
{
|
||||
name: 'proxies',
|
||||
label: '代理',
|
||||
activeNames: ['proxies'],
|
||||
iconPaths:
|
||||
'<path d="m16 3 4 4-4 4"/><path d="M20 7H4"/><path d="m8 21-4-4 4-4"/><path d="M4 17h16"/>',
|
||||
},
|
||||
{
|
||||
name: 'logs',
|
||||
label: '日志',
|
||||
activeNames: ['logs'],
|
||||
iconPaths:
|
||||
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: '设置',
|
||||
activeNames: ['settings'],
|
||||
iconPaths:
|
||||
'<line x1="21" y1="4" x2="14" y2="4"/><line x1="10" y1="4" x2="3" y2="4"/><line x1="21" y1="12" x2="12" y2="12"/><line x1="8" y1="12" x2="3" y2="12"/><line x1="21" y1="20" x2="16" y2="20"/><line x1="12" y1="20" x2="3" y2="20"/><line x1="14" y1="2" x2="14" y2="6"/><line x1="8" y1="10" x2="8" y2="14"/><line x1="16" y1="18" x2="16" y2="22"/>',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const allItems = navGroups.flatMap((g) => g.items)
|
||||
|
||||
export function findNavItem(name: string): NavItem {
|
||||
const item = allItems.find((i) => i.name === name)
|
||||
if (!item) throw new Error(`unknown nav item: ${name}`)
|
||||
return item
|
||||
}
|
||||
|
||||
/** 移动端底栏高频四项;其余进「更多」抽屉 */
|
||||
export const mobileTabNames = ['overview', 'tenants', 'instances', 'logs']
|
||||
export const mobileMoreNames = [
|
||||
'network',
|
||||
'boot-volumes',
|
||||
'object-storage',
|
||||
'tasks',
|
||||
'ai-gateway',
|
||||
'proxies',
|
||||
'settings',
|
||||
]
|
||||
@@ -33,9 +33,15 @@ function makeOverrides(t: ThemeTokens): GlobalThemeOverrides {
|
||||
fontSize: '14px',
|
||||
hoverColor: t.rowHover,
|
||||
},
|
||||
// 按钮两档规范:实体按钮(primary 主操作 / 默认次操作 / error ghost 危险),
|
||||
// 文字按钮一律 quaternary——悬浮统一 wash 底,与图标按钮 hover 一致
|
||||
Button: {
|
||||
borderRadiusMedium: '6px',
|
||||
borderRadiusSmall: '6px',
|
||||
borderRadiusTiny: '5px',
|
||||
fontWeight: '500',
|
||||
colorQuaternaryHover: t.wash,
|
||||
colorQuaternaryPressed: t.washDeep,
|
||||
},
|
||||
// 开关表达「启用状态」而非品牌主色:取状态绿,与 StatusBadge 的
|
||||
// 运行/已启用一致,也避免与相邻红色危险按钮混淆
|
||||
@@ -48,6 +54,9 @@ function makeOverrides(t: ThemeTokens): GlobalThemeOverrides {
|
||||
colorEmbedded: t.card,
|
||||
},
|
||||
DataTable: {
|
||||
// 表格自身不带圆角:一律嵌在 .panel(8px 圆角 + overflow hidden)内,
|
||||
// 自带 6px 圆角会在面板四角与夹层条带(多选/上传)处露出弧形缝隙
|
||||
borderRadius: '0',
|
||||
thColor: t.card,
|
||||
thTextColor: t.ink3,
|
||||
thFontWeight: '500',
|
||||
|
||||
+26
-15
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTabPane, NTabs } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
@@ -14,6 +15,9 @@ import { useToast } from '@/composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** 页面重构为双 Tab:管理面(密钥+渠道)与使用面(接入+模型)不再同屏堆叠 */
|
||||
const tab = ref('manage')
|
||||
|
||||
const keys = useAsync(listAiKeys)
|
||||
const channels = useAsync(listAiChannels)
|
||||
const models = useAsync(listAiModels)
|
||||
@@ -90,11 +94,16 @@ function refreshChannels() {
|
||||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||||
<h1 class="page-title">AI 网关</h1>
|
||||
<span class="text-[13px] text-ink-3">
|
||||
把有 Generative AI 配额的租户接入号池,对外提供 OpenAI / Claude 格式的标准接口
|
||||
对外提供 OpenAI / Claude 格式的标准接口
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="manage" tab="密钥与渠道" />
|
||||
<NTabPane name="access" tab="接入与模型" />
|
||||
</NTabs>
|
||||
|
||||
<div v-if="tab === 'access'" class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<div class="panel">
|
||||
<PanelHeader title="接入信息" />
|
||||
<div class="px-4.5 py-2">
|
||||
@@ -114,7 +123,7 @@ function refreshChannels() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||||
鉴权:Authorization: Bearer ‹密钥› 或 x-api-key: ‹密钥› · 两种头都支持,OpenAI / Anthropic SDK 可直接指向 Base URL
|
||||
鉴权支持 Authorization: Bearer 与 x-api-key 两种头;SDK 直接指向 Base URL 即可
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -174,18 +183,20 @@ function refreshChannels() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiKeyPanel
|
||||
:keys="keys.data.value ?? []"
|
||||
:loading="keys.loading.value"
|
||||
:groups="groups"
|
||||
:models="models.data.value ?? []"
|
||||
@changed="keys.run({ silent: true })"
|
||||
/>
|
||||
<template v-if="tab === 'manage'">
|
||||
<AiKeyPanel
|
||||
:keys="keys.data.value ?? []"
|
||||
:loading="keys.loading.value"
|
||||
:groups="groups"
|
||||
:models="models.data.value ?? []"
|
||||
@changed="keys.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<AiChannelPanel
|
||||
:channels="channels.data.value ?? []"
|
||||
:loading="channels.loading.value"
|
||||
@changed="refreshChannels"
|
||||
/>
|
||||
<AiChannelPanel
|
||||
:channels="channels.data.value ?? []"
|
||||
:loading="channels.loading.value"
|
||||
@changed="refreshChannels"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -137,7 +137,7 @@ function renderActions(row: BvRow) {
|
||||
NButton,
|
||||
{
|
||||
size: 'tiny',
|
||||
tertiary: true,
|
||||
quaternary: true,
|
||||
disabled: gone || attached || row.lifecycleState !== 'AVAILABLE',
|
||||
title: attached ? '已挂载到实例' : undefined,
|
||||
onClick: () => openAttach(row),
|
||||
@@ -148,7 +148,7 @@ function renderActions(row: BvRow) {
|
||||
attached || gone
|
||||
? h(
|
||||
NButton,
|
||||
{ size: 'tiny', type: 'error', ghost: true, disabled: true, title: attached ? '仅未挂载状态可删除' : undefined },
|
||||
{ size: 'tiny', type: 'error', quaternary: true, disabled: true, title: attached ? '仅未挂载状态可删除' : undefined },
|
||||
{ default: () => '删除' },
|
||||
)
|
||||
: h(
|
||||
@@ -161,13 +161,13 @@ function renderActions(row: BvRow) {
|
||||
},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { size: 'tiny', type: 'error', ghost: true }, { default: () => '删除' }),
|
||||
h(NButton, { size: 'tiny', type: 'error', quaternary: true }, { default: () => '删除' }),
|
||||
},
|
||||
)
|
||||
return h('div', { class: 'flex items-center gap-1.5' }, [
|
||||
h(
|
||||
NButton,
|
||||
{ size: 'tiny', tertiary: true, disabled: gone, onClick: () => openEdit(row) },
|
||||
{ size: 'tiny', quaternary: true, disabled: gone, onClick: () => openEdit(row) },
|
||||
{ default: () => '编辑' },
|
||||
),
|
||||
attachBtn,
|
||||
@@ -276,7 +276,7 @@ const columns = computed<DataTableColumns<BvRow>>(() => [
|
||||
:row-key="(r: BvRow) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
引导卷随实例创建产生,不提供单独创建 · 挂载须实例同可用域且处于 STOPPED · 仅未挂载状态可删除
|
||||
挂载须实例同可用域且处于 STOPPED · 仅未挂载状态可删除
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import OcidText from '@/components/OcidText.vue'
|
||||
import RenameModal from '@/components/RenameModal.vue'
|
||||
import { fmtTime, shortAd, truncOcid, vpusLabel } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import {
|
||||
@@ -56,6 +57,10 @@ const message = useToast()
|
||||
const dialog = useDialog()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
|
||||
/** 移动端 hero 元信息默认收起省首屏 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const cfgId = computed(() => Number(route.params.cfgId))
|
||||
const instanceId = computed(() => String(route.params.instanceId))
|
||||
/** 实例所在区域:列表页跳转时经 query 传入,缺失时回退实例对象上的短名(后端已归一化) */
|
||||
@@ -419,24 +424,47 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<h1 class="page-title">{{ instance.data.value.displayName }}</h1>
|
||||
<LifecycleBadge :state="instance.data.value.lifecycleState" />
|
||||
<div class="flex-1" />
|
||||
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
|
||||
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
|
||||
终止
|
||||
</NButton>
|
||||
<NDropdown
|
||||
:options="moreOptions"
|
||||
:disabled="isEnded"
|
||||
@select="(key: string) => power(key as PowerAction)"
|
||||
>
|
||||
<NButton size="small" quaternary :disabled="isEnded">⋯</NButton>
|
||||
</NDropdown>
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton v-if="isStopped" size="small" :disabled="isEnded" @click="power('START')">
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="small" :disabled="isEnded" @click="power('STOP')">停止</NButton>
|
||||
<NButton size="small" :disabled="isEnded" @click="power('RESET')">重启</NButton>
|
||||
<NButton size="small" type="error" ghost :disabled="isEnded" @click="terminate">
|
||||
终止
|
||||
</NButton>
|
||||
<NDropdown
|
||||
:options="moreOptions"
|
||||
:disabled="isEnded"
|
||||
@select="(key: string) => power(key as PowerAction)"
|
||||
>
|
||||
<NButton size="small" quaternary :disabled="isEnded">⋯</NButton>
|
||||
</NDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-5 gap-4 max-lg:grid-cols-3 max-md:grid-cols-1">
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-5 gap-4 max-lg:grid-cols-3 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">租户</div>
|
||||
<div class="mt-0.5 truncate text-[13px]" :title="config.data.value?.tenancyName">
|
||||
@@ -470,7 +498,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||||
<!-- 基本信息 -->
|
||||
<div v-if="instance.data.value" class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">基本信息</div>
|
||||
<NButton size="tiny" quaternary :disabled="isEnded" @click="showRename = true">
|
||||
重命名
|
||||
@@ -546,7 +574,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">网络</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
主网卡地址快捷视图;全部网卡与各卡 IPv6 管理见下方「网卡(VNIC)」
|
||||
主网卡地址快捷视图
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col px-4.5 py-1.5">
|
||||
@@ -599,7 +627,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
|
||||
<!-- 引导卷 -->
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">引导卷</div>
|
||||
<NButton
|
||||
size="small"
|
||||
@@ -671,7 +699,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
|
||||
<!-- 块存储卷 -->
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">块存储卷</div>
|
||||
<NButton
|
||||
size="small"
|
||||
@@ -696,7 +724,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
|
||||
<!-- 控制台连接 -->
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">控制台连接(VNC / 串口)</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<NButton
|
||||
@@ -723,9 +751,7 @@ const consoleColumns: DataTableColumns<ConsoleConnection> = [
|
||||
:row-key="(r: ConsoleConnection) => r.id"
|
||||
/>
|
||||
<FootNote>
|
||||
串行终端 / 网页 VNC 在浏览器内直连(创建会话时自动清理旧连接,二者同时只能开一个)·
|
||||
「创建连接」用于本地 ssh / VNC 客户端:公钥仅支持 RSA,串口直接执行串口连接串,VNC
|
||||
执行后客户端连 localhost:5900 · 串口登录需实例本地账户密码
|
||||
串行终端 / 网页 VNC 浏览器内直连,同时只能开一个 · 「创建连接」供本地 ssh / VNC 客户端使用(公钥仅支持 RSA)
|
||||
</FootNote>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@ import CompartmentSelect from '@/components/CompartmentSelect.vue'
|
||||
import DeadPlaceholder from '@/components/DeadPlaceholder.vue'
|
||||
import CreateInstanceModal from '@/components/instance/CreateInstanceModal.vue'
|
||||
import LifecycleBadge from '@/components/LifecycleBadge.vue'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import MobileCardList from '@/components/MobileCardList.vue'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { shortAd } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { INSTANCE_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
|
||||
@@ -30,10 +31,11 @@ interface InstanceRow extends Instance {
|
||||
}
|
||||
|
||||
const message = useToast()
|
||||
const { alias } = useRegionAlias()
|
||||
|
||||
/** 租户 / 区域 / 区间来自侧栏全局作用域,页内只做区间切换与搜索 */
|
||||
const scope = useScopeStore()
|
||||
/** ≤767px 表格换卡片列表(设计稿 B2) */
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const keyword = ref('')
|
||||
const showCreate = ref(false)
|
||||
@@ -148,14 +150,11 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '区域 / AD',
|
||||
key: 'region',
|
||||
minWidth: 170,
|
||||
render: (row) =>
|
||||
h('div', [
|
||||
h('div', { class: 'text-[13px]' }, alias(row.region)),
|
||||
h('div', { class: 'text-xs text-ink-3 mt-px' }, shortAd(row.availabilityDomain)),
|
||||
]),
|
||||
// 区域随顶栏全局作用域已确定,列内只标可用域
|
||||
title: 'AD',
|
||||
key: 'availabilityDomain',
|
||||
minWidth: 100,
|
||||
render: (row) => h('span', { class: 'text-[13px]' }, shortAd(row.availabilityDomain)),
|
||||
},
|
||||
{
|
||||
title: '公网 IP',
|
||||
@@ -239,6 +238,7 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
v-if="!isMobile"
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:loading="rows.loading.value"
|
||||
@@ -248,6 +248,63 @@ const columns = computed<DataTableColumns<InstanceRow>>(() => [
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MobileCardList
|
||||
v-if="isMobile && !isDead"
|
||||
:items="filtered"
|
||||
:loading="rows.loading.value"
|
||||
:item-key="(r: InstanceRow) => r.id"
|
||||
empty-text="当前作用域下没有实例"
|
||||
>
|
||||
<template #default="{ item: r }">
|
||||
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||
<RouterLink
|
||||
class="min-w-0 truncate text-[14px] font-medium text-ink"
|
||||
:to="{
|
||||
name: 'instance-detail',
|
||||
params: { cfgId: r.cfgId, instanceId: r.id },
|
||||
query: { region: scope.region || undefined },
|
||||
}"
|
||||
>
|
||||
{{ r.displayName }}
|
||||
</RouterLink>
|
||||
<LifecycleBadge :state="r.lifecycleState" class="flex-none" />
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-ink-3">{{ r.cfgAlias }}</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-2">
|
||||
<span>{{ r.shape }}</span>
|
||||
<span>{{ r.ocpus }} OCPU · {{ r.memoryInGBs }} GB</span>
|
||||
<span>{{ shortAd(r.availabilityDomain) }}</span>
|
||||
<span class="mono">{{ r.publicIp || '—' }}</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center gap-1 border-t border-line-soft pt-2">
|
||||
<NButton
|
||||
v-if="r.lifecycleState === 'STOPPED'"
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="isEnded(r)"
|
||||
@click="power(r, 'START')"
|
||||
>
|
||||
启动
|
||||
</NButton>
|
||||
<NButton v-else size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'STOP')">
|
||||
停止
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :disabled="isEnded(r)" @click="power(r, 'RESET')">
|
||||
重启
|
||||
</NButton>
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
type="error"
|
||||
:disabled="isEnded(r)"
|
||||
@click="confirmTerminate(r)"
|
||||
>
|
||||
终止
|
||||
</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
|
||||
<CreateInstanceModal
|
||||
v-model:show="showCreate"
|
||||
:cfg-id="scope.currentConfig?.id ?? null"
|
||||
|
||||
@@ -152,7 +152,7 @@ const columns = computed<DataTableColumns<VcnRow>>(() => [
|
||||
VCN
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
创建 VCN 默认启用 IPv6(Oracle GUA /56)并将默认安全列表替换为出入全放行;如需收紧请再更新安全列表
|
||||
新建默认启用 IPv6 并放行全部出入流量,可在 VCN 详情收紧
|
||||
</div>
|
||||
</div>
|
||||
<NButton size="small" type="primary" @click="showCreate = true">创建 VCN</NButton>
|
||||
|
||||
@@ -306,10 +306,9 @@ const columns = computed<DataTableColumns<Bucket>>(() => [
|
||||
:cfg-id="cfgId"
|
||||
:region="scope.region || undefined"
|
||||
:bucket="activeBucket"
|
||||
:versioning-on="activeBucketInfo?.versioningOn"
|
||||
:visibility="activeBucketInfo?.visibility"
|
||||
:namespace="activeBucketInfo?.namespace"
|
||||
:info="activeBucketInfo"
|
||||
@back="backToList"
|
||||
@settings="activeBucketInfo && openSettings(activeBucketInfo)"
|
||||
/>
|
||||
|
||||
<FormModal
|
||||
|
||||
@@ -156,7 +156,7 @@ const currencySymbol = computed(() => {
|
||||
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5"
|
||||
class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="text-sm font-semibold">租户测活状态</div>
|
||||
<div class="text-[12.5px] text-ink-3">
|
||||
@@ -202,7 +202,7 @@ const currencySymbol = computed(() => {
|
||||
|
||||
<!-- 最近任务执行 -->
|
||||
<div class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">最近任务执行</div>
|
||||
<RouterLink
|
||||
class="text-[13px] font-medium text-accent hover:text-accent-hover"
|
||||
|
||||
@@ -9,10 +9,10 @@ const panel = ref<InstanceType<typeof ProxyPanel> | null>(null)
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<h1 class="page-title">代理</h1>
|
||||
<span class="text-[13px] text-ink-3">出站代理管理,租户关联后 SDK 请求经代理发出</span>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="truncate text-[13px] text-ink-3">出站代理管理</span>
|
||||
<div class="ml-auto flex flex-none items-center gap-2">
|
||||
<NButton size="small" @click="panel?.openImport()">批量导入</NButton>
|
||||
<NButton size="small" type="primary" @click="panel?.openCreate()">新增代理</NButton>
|
||||
</div>
|
||||
|
||||
@@ -89,11 +89,11 @@ const eventRows: { key: keyof NotifyEventsSetting; label: string; hint: string }
|
||||
{ key: 'taskStop', label: '任务停止', hint: '抢机连续鉴权失败熔断停止时推送' },
|
||||
{ key: 'loginLock', label: '登录锁定', hint: '同一 IP+用户名连续失败达阈值时推送' },
|
||||
{ key: 'modelDeprecated', label: '模型弃用预警', hint: 'AI 网关在池模型 30 天内被 OCI 弃用时随每日探测推送' },
|
||||
{ key: 'logEventInstance', label: '实例生命周期', hint: '终止 / 电源操作(Terminate、InstanceAction);创建不回传,避免抢机噪声' },
|
||||
{ key: 'logEventInstance', label: '实例生命周期', hint: '终止 / 电源操作;创建不回传,避免抢机噪声' },
|
||||
{ key: 'logEventIdentity', label: '用户与凭据', hint: '用户增删改、API Key 增删、能力变更' },
|
||||
{ key: 'logEventPolicy', label: '策略变更', hint: 'IAM Policy 创建 / 修改 / 删除' },
|
||||
{ key: 'logEventRegion', label: '区域订阅', hint: '订阅新区域(CreateRegionSubscription)' },
|
||||
{ key: 'logEventLogin', label: '控制台登录', hint: 'OCI 控制台交互登录(InteractiveLogin)' },
|
||||
{ key: 'logEventRegion', label: '区域订阅', hint: '订阅新区域' },
|
||||
{ key: 'logEventLogin', label: '控制台登录', hint: 'OCI 控制台登录' },
|
||||
]
|
||||
|
||||
/** 通知管理按业务域分组展示;新事件类型加入对应组即可 */
|
||||
@@ -206,10 +206,10 @@ const savingTask = ref(false)
|
||||
|
||||
/** 抢机熔断流程步骤条;hot 标记可配置的关键一步 */
|
||||
const CIRCUIT_STEPS = [
|
||||
{ t: '连续失败', d: '抢机请求连续返回 NotAuthenticated', hot: false },
|
||||
{ t: '连续失败', d: '抢机连续 NotAuthenticated', hot: false },
|
||||
{ t: '达到阈值', d: '失败次数达到下方配置值', hot: true },
|
||||
{ t: '熔断停止', d: '任务置为已熔断并退出调度', hot: false },
|
||||
{ t: '推送与恢复', d: '发送「任务停止」通知,修复 Key 后在任务页重新启用', hot: false },
|
||||
{ t: '推送与恢复', d: '发送「任务停止」通知', hot: false },
|
||||
]
|
||||
|
||||
watch(
|
||||
@@ -387,7 +387,7 @@ async function sendTest() {
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">通知管理</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
按事件类型控制是否推送;开关即时生效,事件仅在状态变化时触发
|
||||
按事件类型控制是否推送;事件仅在状态变化时触发
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
@@ -588,7 +588,7 @@ async function sendTest() {
|
||||
<div class="border-b border-line-soft px-4.5 py-3.5">
|
||||
<div class="text-sm font-semibold">访问防护</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
登录守卫与全局 IP 限速,超限返回 429;锁定事件可推送告警(通知管理 → 登录锁定)
|
||||
登录守卫与全局 IP 限速,超限返回 429
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-4 px-4.5 py-3.5 max-md:grid-cols-1">
|
||||
@@ -694,8 +694,7 @@ async function sendTest() {
|
||||
</FormField>
|
||||
</div>
|
||||
<FootNote>
|
||||
登录锁定为内存计数,重启服务即清零;真实IP解析对 X-Forwarded-For
|
||||
取链尾(反代追加的直连对端),链首可被客户端伪造不采信
|
||||
登录锁定为内存计数,重启即清零
|
||||
</FootNote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import SubsTab from '@/components/tenant/SubsTab.vue'
|
||||
import UsersTab from '@/components/tenant/UsersTab.vue'
|
||||
import { aliveMeta } from '@/composables/useAliveStatus'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
@@ -36,6 +37,10 @@ const cfgId = computed(() => Number(route.params.id))
|
||||
const tab = ref('quota')
|
||||
const verifying = ref(false)
|
||||
|
||||
/** 移动端 hero 元信息默认收起省首屏;域选择器移出 Tab suffix 独立成行 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const config = useAsync(() => getConfig(cfgId.value))
|
||||
|
||||
/** 失联/暂停租户各 tab 拉不到云端数据,内容区整体换占位 */
|
||||
@@ -135,22 +140,45 @@ async function remove() {
|
||||
/>
|
||||
<AccountTypeBadge :type="config.data.value.accountType" />
|
||||
<div class="flex-1" />
|
||||
<NButton size="small" :loading="verifying" @click="verify">立即测活</NButton>
|
||||
<ConfirmPop
|
||||
:title="`删除「${config.data.value.alias}」?`"
|
||||
content="将清理面板内关联数据。"
|
||||
note="云端日志回传链路请先在「其他」页销毁"
|
||||
confirm-text="删除"
|
||||
placement="bottom-end"
|
||||
:on-confirm="remove"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除租户</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="small" :loading="verifying" @click="verify">立即测活</NButton>
|
||||
<ConfirmPop
|
||||
:title="`删除「${config.data.value.alias}」?`"
|
||||
content="将清理面板内关联数据。"
|
||||
note="云端日志回传链路请先在「其他」页销毁"
|
||||
confirm-text="删除"
|
||||
placement="bottom-end"
|
||||
:on-confirm="remove"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除租户</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-4 gap-4 pb-4 max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-4 gap-4 pb-4 max-lg:grid-cols-2 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">租户名</div>
|
||||
<div class="mono mt-0.5 truncate text-[13px]">{{ config.data.value.tenancyName }}</div>
|
||||
@@ -168,6 +196,7 @@ async function remove() {
|
||||
<div class="mt-0.5 text-[13px]">{{ fmtTime(config.data.value.createdAt).slice(0, 10) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isMobile" class="pt-2"></div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line" class="tenant-tabs">
|
||||
<NTabPane name="quota" tab="配额" />
|
||||
@@ -178,13 +207,18 @@ async function remove() {
|
||||
<NTabPane name="subs" tab="订阅" />
|
||||
<NTabPane name="audit" tab="审计日志" />
|
||||
<NTabPane name="misc" tab="其他" />
|
||||
<template #suffix>
|
||||
<template v-if="!isMobile" #suffix>
|
||||
<!-- 多域租户:用户/IDP/其他 tab 按选中域读写 -->
|
||||
<div v-if="showDomainPicker" class="w-64 max-md:w-44">
|
||||
<div v-if="showDomainPicker" class="w-64">
|
||||
<NSelect v-model:value="domainId" size="small" :options="domainOptions" placeholder="身份域" />
|
||||
</div>
|
||||
</template>
|
||||
</NTabs>
|
||||
<!-- 移动端:域选择器独立成行,不与 Tab 条抢横向空间 -->
|
||||
<div v-if="isMobile && showDomainPicker" class="flex items-center gap-2 pt-2.5 pb-3">
|
||||
<span class="flex-none text-xs text-ink-3">身份域</span>
|
||||
<NSelect v-model:value="domainId" size="small" :options="domainOptions" placeholder="身份域" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="config.data.value">
|
||||
|
||||
@@ -10,10 +10,12 @@ import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
|
||||
import EditConfigModal from '@/components/config/EditConfigModal.vue'
|
||||
import ImportConfigModal from '@/components/config/ImportConfigModal.vue'
|
||||
import MobileCardList from '@/components/MobileCardList.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { parseSnatch, scopeCfgIds, STATUS_META, TYPE_LABEL } from '@/components/task/taskDisplay'
|
||||
import { aliveMeta } from '@/composables/useAliveStatus'
|
||||
import { fmtRelative, fmtTime } from '@/composables/useFormat'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import { useRegionAlias } from '@/composables/useRegionAlias'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useScopeStore } from '@/stores/scope'
|
||||
@@ -24,6 +26,8 @@ const message = useToast()
|
||||
const router = useRouter()
|
||||
const { alias: regionAlias } = useRegionAlias()
|
||||
const scope = useScopeStore()
|
||||
/** ≤767px 表格换卡片列表(设计稿 B2) */
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
/** 租户增删改 / 测活后本页列表与全局作用域选择器一起刷新 */
|
||||
function reloadConfigs() {
|
||||
@@ -353,6 +357,7 @@ const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
<NDataTable
|
||||
v-if="!isMobile"
|
||||
:columns="columns"
|
||||
:data="filtered"
|
||||
:loading="configs.loading.value"
|
||||
@@ -362,6 +367,66 @@ const columns = computed<DataTableColumns<OciConfigSummary>>(() => [
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MobileCardList
|
||||
v-if="isMobile"
|
||||
:items="filtered"
|
||||
:loading="configs.loading.value"
|
||||
:item-key="(c: OciConfigSummary) => c.id"
|
||||
empty-text="没有匹配的租户"
|
||||
>
|
||||
<template #default="{ item: c }">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<RouterLink
|
||||
class="min-w-0 truncate text-[14px] font-medium text-ink"
|
||||
:to="{ name: 'tenant-detail', params: { id: c.id } }"
|
||||
>
|
||||
{{ c.alias }}
|
||||
</RouterLink>
|
||||
<AccountTypeBadge :type="c.accountType" class="flex-none" />
|
||||
<StatusBadge
|
||||
:kind="aliveMeta(c.aliveStatus).kind"
|
||||
:label="aliveMeta(c.aliveStatus).label"
|
||||
class="flex-none"
|
||||
/>
|
||||
</div>
|
||||
<div class="mono mt-0.5 truncate text-xs text-ink-3">{{ c.tenancyName }}</div>
|
||||
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-2">
|
||||
<span>{{ regionAlias(c.region) }}</span>
|
||||
<span v-if="c.group">{{ c.group }}</span>
|
||||
<span class="text-ink-3">导入 {{ fmtTime(c.createdAt).slice(0, 10) }}</span>
|
||||
<span class="text-ink-3">测活 {{ fmtRelative(c.lastVerifiedAt) }}</span>
|
||||
<span v-if="tasksFor(c.id).length" class="text-ink-3">
|
||||
任务 {{ tasksFor(c.id).length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center gap-1 border-t border-line-soft pt-2">
|
||||
<NButton
|
||||
size="tiny"
|
||||
quaternary
|
||||
:disabled="scope.cfgId === c.id && scope.region === c.region"
|
||||
@click="setGlobal(c)"
|
||||
>
|
||||
设为全局
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary :loading="verifying === c.id" @click="verify(c)">
|
||||
测活
|
||||
</NButton>
|
||||
<NButton size="tiny" quaternary @click="edit(c)">修改</NButton>
|
||||
<ConfirmPop
|
||||
:title="`删除「${c.alias}」?`"
|
||||
content="将清理面板内关联数据。"
|
||||
note="云端日志回传链路请先在「其他」页销毁"
|
||||
confirm-text="删除"
|
||||
:on-confirm="() => remove(c)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" quaternary type="error">删除</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
|
||||
<ImportConfigModal v-model:show="showImport" @imported="reloadConfigs()" />
|
||||
<EditConfigModal v-model:show="showEdit" :config="editing" @updated="reloadConfigs()" />
|
||||
</div>
|
||||
|
||||
+39
-15
@@ -25,12 +25,16 @@ import RenameModal from '@/components/RenameModal.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import type { Ipv6Step, SecurityList, SecurityRule, Subnet } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useToast()
|
||||
/** 移动端 hero meta 默认收起,与租户/实例详情一致 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const cfgId = computed(() => Number(route.params.cfgId))
|
||||
const vcnId = computed(() => String(route.params.vcnId))
|
||||
@@ -279,21 +283,41 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
|
||||
:dot="false"
|
||||
/>
|
||||
<div class="flex-1" />
|
||||
<NButton size="small" @click="showRename = true">重命名</NButton>
|
||||
<ConfirmPop
|
||||
title="删除 VCN?"
|
||||
content="其关联资源将一并删除。"
|
||||
note="子网仍被实例占用时会失败,需先终止实例"
|
||||
confirm-text="删除"
|
||||
placement="bottom-end"
|
||||
:on-confirm="removeVcn"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除 VCN</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="small" @click="showRename = true">重命名</NButton>
|
||||
<ConfirmPop
|
||||
title="删除 VCN?"
|
||||
content="其关联资源将一并删除。"
|
||||
note="子网仍被实例占用时会失败,需先终止实例"
|
||||
confirm-text="删除"
|
||||
placement="bottom-end"
|
||||
:on-confirm="removeVcn"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="small" type="error" ghost>删除 VCN</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1">
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">IPv4 CIDR</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ (vcn.data.value.cidrBlocks ?? []).join(', ') }}</div>
|
||||
@@ -316,7 +340,7 @@ const stepColumns: DataTableColumns<Ipv6Step> = [
|
||||
</div>
|
||||
|
||||
<div v-if="vcn.data.value" class="panel">
|
||||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-2.5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-y-2 border-b border-line-soft px-4.5 py-2.5">
|
||||
<div class="text-[13.5px] font-semibold">便捷功能</div>
|
||||
<div class="text-xs text-ink-3">执行前均会二次确认</div>
|
||||
</div>
|
||||
|
||||
+78
-1
@@ -3,9 +3,78 @@ import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
type GetModuleInfo = (id: string) => { importers: readonly string[] } | null
|
||||
|
||||
/** pako / jszip 等 office 深依赖路径无特征,沿 importer 链上溯:
|
||||
* 任一条链经过 @vue-office 即视为 office 生态模块 */
|
||||
function fromVueOffice(id: string, getModuleInfo: GetModuleInfo, seen = new Set<string>()): boolean {
|
||||
if (seen.has(id)) return false
|
||||
seen.add(id)
|
||||
const importers = getModuleInfo(id)?.importers ?? []
|
||||
return importers.some(
|
||||
(imp) => imp.includes('@vue-office') || fromVueOffice(imp, getModuleInfo, seen),
|
||||
)
|
||||
}
|
||||
|
||||
/** 大体积三方库归入命名 chunk:主入口显著瘦身,命名亦供 PWA 预缓存排除
|
||||
* (这些块全部只被懒加载路径引用,拆出不影响首屏)。 */
|
||||
function vendorChunk(id: string, getModuleInfo: GetModuleInfo): string | undefined {
|
||||
if (!id.includes('node_modules')) return undefined
|
||||
if (id.includes('@vue-office/pdf') || id.includes('pdfjs')) return 'office-pdf'
|
||||
if (id.includes('@vue-office/excel') || id.includes('exceljs') || id.includes('xlsx'))
|
||||
return 'office-excel'
|
||||
if (id.includes('@vue-office/docx') || id.includes('docx')) return 'office-docx'
|
||||
if (id.includes('echarts') || id.includes('zrender')) return 'echarts'
|
||||
if (id.includes('naive-ui')) return 'naive-ui'
|
||||
if (id.includes('highlight.js')) return 'hljs'
|
||||
if (fromVueOffice(id, getModuleInfo)) return 'office-shared'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'OCI Portal',
|
||||
short_name: 'OCI Portal',
|
||||
description: '自托管 OCI 多租户管理面板',
|
||||
lang: 'zh-CN',
|
||||
display: 'standalone',
|
||||
theme_color: '#F5F4ED',
|
||||
background_color: '#F5F4ED',
|
||||
icons: [
|
||||
{ src: '/pwa-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png' },
|
||||
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2}'],
|
||||
// 预缓存只收应用壳(入口/naive-ui/echarts/路由块);office 全家
|
||||
// (pdf/excel/docx/shared,合计 ~6MB)安装时不下载,首次使用再入缓存
|
||||
globIgnores: ['**/office-*.js'],
|
||||
navigateFallback: 'index.html',
|
||||
// API 与 AI 网关直通不落 SPA 壳也不缓存:运维数据必须实时
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/ai\//],
|
||||
runtimeCaching: [
|
||||
{
|
||||
// 未进预缓存的大块兜底:assets 带内容 hash 不可变,CacheFirst 恒安全
|
||||
urlPattern: /\/assets\/.+\.js$/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'lazy-vendor',
|
||||
expiration: { maxEntries: 30, maxAgeSeconds: 30 * 24 * 3600 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
@@ -15,6 +84,14 @@ export default defineConfig({
|
||||
// 生产构建与 dev 依赖预构建(esbuild)都要提高 target,否则 dev 会报同一错误
|
||||
build: {
|
||||
target: 'es2022',
|
||||
// 现存最大懒块 office-pdf(pdfjs 全量)~2.8MB,均为按需加载不入首屏;
|
||||
// 阈值取其上放宽,只为「新失控块」鸣笛而不对既有三方整包常鸣
|
||||
chunkSizeWarningLimit: 2900,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: (id, { getModuleInfo }) => vendorChunk(id, getModuleInfo),
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
|
||||
Reference in New Issue
Block a user