三页面重设计与移动端适配;接入 PWA 与构建分包
CI / test (push) Successful in 47s

This commit is contained in:
2026-07-21 19:26:22 +08:00
parent ac457282ce
commit 6129173fe6
64 changed files with 6243 additions and 549 deletions
+33
View File
@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useIsMobile } from '@/composables/useIsMobile'
/** 筛选器统一布局:桌面 flex-wrap 换行,移动端横滚一行 + 右缘渐隐暗示,
* 条目不换行不压缩(替代 max-md:!w-full 逐行堆叠吃满竖向空间的旧模式) */
const isMobile = useIsMobile()
</script>
<template>
<div :class="isMobile ? 'filter-scroll' : 'flex flex-wrap items-center gap-x-3 gap-y-2'">
<slot />
</div>
</template>
<style scoped>
.filter-scroll {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 24px), transparent);
padding-right: 24px;
}
.filter-scroll::-webkit-scrollbar {
display: none;
}
.filter-scroll > :deep(*) {
flex: none;
white-space: nowrap;
}
</style>
+34 -5
View File
@@ -1,7 +1,10 @@
<script setup lang="ts">
import { NButton, NModal } from 'naive-ui'
import { computed } from 'vue'
defineProps<{
import { useIsMobile } from '@/composables/useIsMobile'
const props = defineProps<{
show: boolean
title: string
submitting?: boolean
@@ -12,6 +15,15 @@ defineProps<{
}>()
const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
const isMobile = useIsMobile()
/** 移动端全屏(100dvh 覆盖底栏与手势区),桌面维持定宽居中 */
const modalStyle = computed(() =>
isMobile.value
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
: { width: `${props.width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' },
)
</script>
<template>
@@ -19,7 +31,8 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
:show="show"
preset="card"
:title="title"
:style="{ width: `${width ?? 480}px`, maxWidth: 'calc(100vw - 24px)' }"
:style="modalStyle"
:class="isMobile ? 'form-modal-mobile' : ''"
:mask-closable="!submitting"
:close-on-esc="!submitting"
@update:show="emit('update:show', $event)"
@@ -33,11 +46,17 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
</div>
<template #footer>
<div class="flex justify-end gap-2.5">
<NButton size="small" :disabled="submitting" @click="emit('update:show', false)">
<NButton
:size="isMobile ? 'medium' : 'small'"
:class="isMobile ? 'flex-1' : ''"
:disabled="submitting"
@click="emit('update:show', false)"
>
取消
</NButton>
<NButton
size="small"
:size="isMobile ? 'medium' : 'small'"
:class="isMobile ? 'flex-1' : ''"
:type="danger ? 'error' : 'primary'"
:loading="submitting"
:disabled="submitDisabled"
@@ -54,9 +73,19 @@ const emit = defineEmits<{ 'update:show': [boolean]; submit: [] }>()
/* 长表单不撑破视口:内容区限高滚动,页脚按钮常驻;
负 margin + 等量 padding 让滚动条贴弹窗边缘(变量继承自 .n-card) */
.form-modal-body {
max-height: min(62vh, calc(100vh - 220px));
overflow-y: auto;
margin-inline: calc(-1 * var(--n-padding-left, 24px));
padding-inline: var(--n-padding-left, 24px);
}
/* 限高只在桌面定宽形态生效:移动全屏由全局 .form-modal-mobile 的 flex:1
接管高度。不能靠全局规则 max-height:none 覆盖——main.css 先于组件
scoped 样式加载,同特异性下这里会反超,故直接用断点隔离 */
@media (min-width: 768px) {
.form-modal-body {
max-height: min(62vh, calc(100vh - 220px));
}
}
</style>
<!-- 移动端全屏形态 .form-modal-mobile 的规则在 main.css(全局):
NModal teleport body,scoped 够不到;且该类被各弹窗跨 chunk 复用 -->
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts" generic="T">
import { NSpin } from 'naive-ui'
/** 移动端列表卡片化容器:接管加载 / 空态,卡片体由默认插槽渲染 */
const props = defineProps<{
items: T[]
loading?: boolean
itemKey: (item: T) => string | number
emptyText?: string
}>()
</script>
<template>
<div class="flex flex-col gap-2.5">
<div
v-if="props.loading && !props.items.length"
class="panel flex items-center justify-center py-10"
>
<NSpin size="small" />
</div>
<div v-else-if="!props.items.length" class="panel py-10 text-center text-[13px] text-ink-3">
{{ props.emptyText ?? '暂无数据' }}
</div>
<div v-for="it in props.items" v-else :key="props.itemKey(it)" class="panel px-4 py-3">
<slot :item="it"></slot>
</div>
</div>
</template>
+10 -10
View File
@@ -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>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { NPopover } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useRegionAlias } from '@/composables/useRegionAlias'
/** 租户悬停卡(AI 网关渠道 / 回传日志共用):别名、租户名称、主区域三行
* 加「前往租户」入口;触发文本本身不承载跳转,避免误触。 */
const props = defineProps<{
cfgId: number
/** 触发文本(渠道名 / 租户别名) */
label: string
alias?: string
tenancyName?: string
region?: string
/** 触发文本附加类(截断约束等) */
labelClass?: string
}>()
const router = useRouter()
const { alias: regionAlias } = useRegionAlias()
function go(e: MouseEvent) {
// 表格行自身可能有点击行为(如回传日志展开详情),跳转不冒泡
e.stopPropagation()
void router.push({ name: 'tenant-detail', params: { id: props.cfgId } })
}
</script>
<template>
<NPopover trigger="hover" placement="top-start" :style="{ maxWidth: '320px' }">
<template #trigger>
<span
class="cursor-help border-b border-dashed border-ink-3/50 text-[13px]"
:class="labelClass"
>
{{ label }}
</span>
</template>
<div class="flex min-w-40 flex-col text-xs">
<div class="pb-1.5 text-[13px] font-semibold break-all">{{ alias ?? label }}</div>
<div v-if="tenancyName" class="mono border-t border-line-soft py-1.5 break-all text-ink-2">
{{ tenancyName }}
</div>
<div v-if="region" class="border-t border-line-soft py-1.5 text-ink-2">
{{ regionAlias(region) }}
</div>
<button
type="button"
class="cursor-pointer border-t border-line-soft pt-1.5 text-left font-medium text-accent"
@click="go"
>
前往租户
</button>
</div>
</NPopover>
</template>
+1 -1
View File
@@ -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>
+4 -1
View File
@@ -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>
+19 -5
View File
@@ -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
+2 -2
View File
@@ -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)),
}
})
+2 -3
View File
@@ -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
+11 -2
View File
@@ -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"
+45 -53
View File
@@ -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"
+25 -10
View File
@@ -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"
+6 -3
View File
@@ -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">
+340 -100
View File
@@ -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
+14 -19
View File
@@ -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>
+4 -1
View File
@@ -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/&lt;github|oidc&gt;/callback;secret 加密存储不回显 ·
需先在「网络与地址」保存面板地址
在 IdP / GitHub 应用中填写的授权回调地址:
{{ callbackBase}}/api/v1/auth/oauth/&lt;github|oidc&gt;/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">
<!-- 亮色下透明融入弹窗底;暗色下保留浅底(黑模块二维码的可扫性要求) -->
+4 -5
View File
@@ -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 -7
View File
@@ -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"
+3 -4
View File
@@ -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>
+4 -1
View File
@@ -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">
+6 -2
View File
@@ -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>
-2
View File
@@ -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"
+1 -1
View File
@@ -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 &amp;
Cost Management Payment Methods操作,完成后回本页刷新即可
加换卡片请在 OCI 控制台Billing &amp;
Cost Management Payment Methods操作
</div>
</NModal>
</template>
+50 -43
View File
@@ -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>
+2 -2
View File
@@ -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 }} ·
+1 -1
View File
@@ -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>