新增对象存储/账单/保留IP页面,成本今天视图与样式收敛
CI / test (push) Successful in 42s

This commit is contained in:
2026-07-21 12:11:22 +08:00
parent d813225ac4
commit ac457282ce
91 changed files with 6672 additions and 652 deletions
+83
View File
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { NTreeSelect, type TreeSelectOption } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import type { Compartment } from '@/types/api'
/** 区间选择器:按 parentId 组树,与官方控制台一致分层展示;
* 父级不在列表中的节点视为根 compartment 的直接子级,根以 value='' 表示。 */
const props = withDefaults(
defineProps<{
value: string
compartments: Compartment[]
rootLabel: string
loading?: boolean
disabled?: boolean
size?: 'small' | 'medium'
}>(),
{ size: 'small' },
)
const emit = defineEmits<{ 'update:value': [string] }>()
function groupByParent(list: Compartment[]): Map<string, Compartment[]> {
const ids = new Set(list.map((c) => c.id))
const map = new Map<string, Compartment[]>()
for (const c of list) {
const parent = ids.has(c.parentId) ? c.parentId : ''
map.set(parent, [...(map.get(parent) ?? []), c])
}
for (const arr of map.values()) arr.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
return map
}
function toNodes(map: Map<string, Compartment[]>, parent: string): TreeSelectOption[] | undefined {
const list = map.get(parent)
if (!list) return undefined
return list.map((c) => ({ label: c.name, key: c.id, children: toNodes(map, c.id) }))
}
const tree = computed<TreeSelectOption[]>(() => {
const map = groupByParent(props.compartments)
return [{ label: props.rootLabel, key: '', children: toNodes(map, '') }]
})
/** 选项异步加载,default-expand-all 不可靠,改为受控展开全部父节点 */
const expandedKeys = ref<Array<string | number>>([])
watch(
tree,
(nodes) => {
const keys: string[] = []
const walk = (list: TreeSelectOption[]) => {
for (const n of list) {
if (!n.children?.length) continue
keys.push(String(n.key))
walk(n.children)
}
}
walk(nodes)
expandedKeys.value = keys
},
{ immediate: true },
)
function onUpdate(v: string | number | Array<string | number> | null) {
emit('update:value', typeof v === 'string' ? v : '')
}
</script>
<template>
<NTreeSelect
class="min-w-0"
:value="value"
:options="tree"
:expanded-keys="expandedKeys"
:loading="loading"
:disabled="disabled"
:size="size"
:indent="18"
:consistent-menu-width="false"
filterable
@update:value="onUpdate"
@update:expanded-keys="(keys: Array<string | number>) => (expandedKeys = keys)"
/>
</template>
+144
View File
@@ -0,0 +1,144 @@
<script setup lang="ts">
import { NButton, NPopover, type PopoverPlacement } from 'naive-ui'
import { ref } from 'vue'
export type ConfirmKind = 'danger' | 'warn' | 'plain'
/**
* 统一确认气泡:替代 NPopconfirm 默认样式(批次8 设计稿)。
* onConfirm 返回 Promise 时确认按钮进入 loading,期间弹层不可关闭、不可重复提交。
*/
const props = withDefaults(
defineProps<{
title: string
content?: string
note?: string
kind?: ConfirmKind
confirmText?: string
cancelText?: string
placement?: PopoverPlacement
onConfirm?: () => unknown
}>(),
{
kind: 'danger',
confirmText: '确认',
cancelText: '取消',
placement: 'top',
content: undefined,
note: undefined,
onConfirm: undefined,
},
)
const show = ref(false)
const loading = ref(false)
function onUpdateShow(v: boolean) {
if (loading.value) return
show.value = v
}
async function confirm() {
if (loading.value) return
const r = props.onConfirm?.()
if (r instanceof Promise) {
loading.value = true
try {
await r
} finally {
loading.value = false
}
}
show.value = false
}
</script>
<template>
<NPopover
:show="show"
trigger="click"
:placement="placement"
raw
:show-arrow="false"
@update:show="onUpdateShow"
>
<template #trigger><slot name="trigger" /></template>
<div
class="w-[268px] rounded-lg border border-line bg-white p-4 pb-3.5 shadow-overlay"
>
<div class="flex items-center gap-2.5">
<span
class="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-[7px]"
:class="
kind === 'danger'
? 'bg-err/12 text-err'
: kind === 'warn'
? 'bg-warn/14 text-warn'
: 'bg-wash text-ink-2'
"
>
<svg
v-if="kind === 'danger'"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<svg
v-else-if="kind === 'warn'"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<svg
v-else
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</span>
<!-- 超长资源名(如桶名)单行截断,悬停 tooltip 看全文 -->
<span class="min-w-0 truncate text-[13.5px] font-semibold" :title="title">{{ title }}</span>
</div>
<div v-if="content" class="mt-2 pl-9 text-[12.5px] leading-relaxed text-ink-2">
{{ content }}
</div>
<div v-if="note" class="mt-1 pl-9 text-xs leading-normal text-ink-3">{{ note }}</div>
<div class="mt-3 flex justify-end gap-2">
<NButton size="small" :disabled="loading" @click="show = false">{{ cancelText }}</NButton>
<NButton
size="small"
:type="kind === 'danger' ? 'error' : 'primary'"
:loading="loading"
@click="confirm"
>
{{ confirmText }}
</NButton>
</div>
</div>
</NPopover>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
/** 面板标准头:统一 px-4.5 py-3.5 内距、text-sm 标题、text-xs 弱化描述。
* 右侧动作放默认插槽;标题行内的徽标/计数放 #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="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>
</div>
<div v-if="$slots.default" class="flex flex-wrap items-center gap-2">
<slot />
</div>
</div>
</template>
+7 -2
View File
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { NInput, NPopover, NSpin } from 'naive-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import type { OciConfigSummary } from '@/types/api'
/** 租户选择器:Popover 面板(搜索 + 左分组导航 + 右租户列表),替代平铺下拉。
* 单选(v-model:value)点行即选即关;多选(multiple + v-model:values)点行切换勾选。
* 搜索命中时忽略左侧分组限定。 */
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
configs: OciConfigSummary[]
@@ -23,6 +24,9 @@ const props = withDefaults(
)
const emit = defineEmits<{ 'update:value': [number | null]; 'update:values': [number[]] }>()
/** 根组件是 NPopover,class 透传会落到弹出面板上把宽度压扁;改为只作用于默认触发按钮 */
const attrs = useAttrs()
/** 「未分组」导航项哨兵(空串已被「全部租户」占用) */
const UNGROUPED = '__ungrouped__'
@@ -107,6 +111,7 @@ const showClear = computed(
type="button"
class="group flex w-full cursor-pointer items-center gap-2 rounded-lg border bg-white px-3 text-left transition-colors"
:class="[
attrs.class as string,
size === 'small' ? 'h-7' : 'h-[34px]',
show ? 'border-accent ring-2 ring-accent/15' : 'border-line hover:border-accent/60',
]"
@@ -138,7 +143,7 @@ const showClear = computed(
</slot>
</template>
<div class="w-[min(560px,92vw)] overflow-hidden rounded-xl border border-line bg-card shadow-[0_18px_44px_rgba(20,20,19,0.16)]">
<div class="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>
+6 -8
View File
@@ -6,6 +6,7 @@ import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } fro
import { listCachedRegions } from '@/api/configs'
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
import GroupChips from '@/components/ai/GroupChips.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
@@ -198,15 +199,12 @@ const columns = computed<DataTableColumns<AiChannel>>(() => [
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">渠道(号池)</div>
<div class="mt-0.5 text-xs text-ink-3">
渠道 = 租户 × 区域;请求按优先级分组组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断
</div>
</div>
<PanelHeader
title="渠道(号池)"
desc="渠道 = 租户 × 区域;请求按优先级分组、组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断"
>
<NButton size="small" type="primary" @click="openCreate">添加渠道</NButton>
</div>
</PanelHeader>
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" />
<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 试调(配额)· 探测成功自动复位熔断 ·
+6 -8
View File
@@ -7,6 +7,7 @@ import GroupChips from '@/components/ai/GroupChips.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtTime } from '@/composables/useFormat'
import type { AiKey, AiModel } from '@/types/api'
@@ -169,15 +170,12 @@ const columns = computed<DataTableColumns<AiKey>>(() => [
<template>
<div class="panel">
<div class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3">
<div>
<div class="text-sm font-semibold">网关密钥</div>
<div class="mt-0.5 text-xs text-ink-3">
对外发放的访问凭证;明文仅创建时展示一次,库内只存哈希与尾 4 ;选了分组的密钥只在该分组渠道内负载均衡
</div>
</div>
<PanelHeader
title="网关密钥"
desc="对外发放的访问凭证;明文仅创建时展示一次,库内只存哈希与尾 4 位;选了分组的密钥只在该分组渠道内负载均衡"
>
<NButton size="small" type="primary" @click="openCreate">新建密钥</NButton>
</div>
</PanelHeader>
<NDataTable :columns="columns" :data="keys" :loading="loading" :bordered="false" size="small" />
<FormModal
+44 -2
View File
@@ -4,8 +4,13 @@ import { computed, ref, watch } from 'vue'
import { attachVnic } from '@/api/instances'
import { listSubnets } from '@/api/networks'
import { listReservedIps } from '@/api/reservedips'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import {
freeReservedIpOptions,
renderReservedIpLabel,
} from '@/components/network/reservedIpSelect'
import ToggleCard from '@/components/ToggleCard.vue'
import { useAsync } from '@/composables/useAsync'
import { shortAd } from '@/composables/useFormat'
@@ -29,10 +34,21 @@ const displayName = ref('')
const privateIp = ref('')
const assignPublicIp = ref(false)
const assignIpv6 = ref(false)
/** null = 临时公网 IP;选中保留 IP 时网卡就绪后由后台绑定 */
const reservedIpId = ref<string | null>(null)
/** vcnId 传空列全部子网:次要网卡允许跨 VCN */
const subnets = useAsync(() => listSubnets(props.cfgId, '', props.region), false)
const reservedIps = useAsync(async () => {
try {
return await listReservedIps(props.cfgId, props.region)
} catch {
return []
}
}, false)
const reservedOptions = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
watch(
() => props.show,
(v) => {
@@ -42,11 +58,17 @@ watch(
privateIp.value = ''
assignPublicIp.value = false
assignIpv6.value = false
reservedIpId.value = null
void subnets.run()
void reservedIps.run()
}
},
)
watch(assignPublicIp, (on) => {
if (!on) reservedIpId.value = null
})
const options = computed<SelectOption[]>(() =>
(subnets.data.value ?? [])
.filter((s) => s.lifecycleState === 'AVAILABLE')
@@ -75,10 +97,15 @@ async function submit() {
subnetId: subnetId.value,
displayName: displayName.value.trim(),
privateIp: privateIp.value.trim(),
assignPublicIp: assignPublicIp.value,
assignPublicIp: assignPublicIp.value && !reservedIpId.value,
assignIpv6: assignIpv6.value,
reservedPublicIpId: reservedIpId.value ?? undefined,
})
message.success('附加请求已提交,OS 内需自行配置新网卡 IP')
message.success(
reservedIpId.value
? '附加请求已提交,网卡就绪后自动绑定保留 IP;OS 内需自行配置新网卡 IP'
: '附加请求已提交,OS 内需自行配置新网卡 IP',
)
emit('update:show', false)
emit('attached')
} catch (e) {
@@ -128,6 +155,21 @@ async function submit() {
:desc="publicIpBlocked ? '所选子网为私有子网,不可分配公网 IP' : '仅公有子网可用;临时地址,分离即释放'"
:disabled="publicIpBlocked"
/>
<FormField
v-if="assignPublicIp && !publicIpBlocked"
label="公网 IP 类型"
hint="选保留 IP 时网卡就绪后自动绑定,期间短暂无公网地址"
>
<NSelect
v-model:value="reservedIpId"
:options="reservedOptions"
:loading="reservedIps.loading.value"
:render-label="renderReservedIpLabel"
:consistent-menu-width="false"
clearable
placeholder="临时 IP(默认);可选保留 IP"
/>
</FormField>
<ToggleCard
v-model="assignIpv6"
title="自动分配 IPv6"
+116
View File
@@ -0,0 +1,116 @@
<script setup lang="ts">
import { NRadio, NRadioGroup, NSelect } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { changeVnicPublicIp } from '@/api/instances'
import { assignReservedIp, listReservedIps } from '@/api/reservedips'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import { freeReservedIpOptions, renderReservedIpLabel } from '@/components/network/reservedIpSelect'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
/** 换公网 IP 弹窗(主卡/次卡通用):临时 IP 删旧分新,保留 IP 绑定固定地址;
* 两种路径旧公网 IP 都由后端自动释放(临时删除、保留解绑)。 */
const props = defineProps<{
show: boolean
cfgId: number
region?: string
vnicId: string
/** 网卡当前公网 IP,仅展示 */
currentIp?: string
}>()
const emit = defineEmits<{
'update:show': [boolean]
/** 换 IP 成功;newIp 为预期新地址,供父级做收敛轮询(拿不到时 undefined) */
changed: [payload: { vnicId: string; newIp?: string }]
}>()
const message = useToast()
const ipType = ref<'ephemeral' | 'reserved'>('ephemeral')
const reservedId = ref<string | null>(null)
const busy = ref(false)
const reservedIps = useAsync(async () => {
try {
return await listReservedIps(props.cfgId, props.region)
} catch {
return []
}
}, false)
const options = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
watch(
() => props.show,
(show) => {
if (!show) return
ipType.value = 'ephemeral'
reservedId.value = null
void reservedIps.run()
},
)
async function submit() {
if (busy.value) return
busy.value = true
try {
const newIp = await applyChange()
emit('update:show', false)
emit('changed', { vnicId: props.vnicId, newIp })
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
busy.value = false
}
}
/** 执行换 IP 并返回预期新地址;保留 IP 取所选项地址,拿不到时 undefined(轮询退化为比对旧值) */
async function applyChange(): Promise<string | undefined> {
if (ipType.value === 'reserved') {
await assignReservedIp(props.cfgId, reservedId.value!, '', props.region, props.vnicId)
message.success('保留 IP 已绑定,原公网 IP 已释放')
return (reservedIps.data.value ?? []).find((r) => r.id === reservedId.value)?.ipAddress || undefined
}
const { publicIp } = await changeVnicPublicIp(props.cfgId, props.vnicId, props.region)
message.success(`公网 IP 已更换为 ${publicIp}`)
return publicIp
}
</script>
<template>
<FormModal
:show="show"
title="更换公网 IP"
:width="440"
:submitting="busy"
:submit-disabled="ipType === 'reserved' && !reservedId"
submit-text="更换"
@update:show="emit('update:show', $event)"
@submit="submit"
>
<FormField v-if="currentIp" label="当前地址">
<span class="mono text-[12.5px]">{{ currentIp }}</span>
</FormField>
<FormField label="新地址类型" hint="临时 IP 删除;保留 IP 解绑回到未分配">
<NRadioGroup v-model:value="ipType">
<NRadio value="ephemeral">临时 IP</NRadio>
<NRadio value="reserved">保留 IP</NRadio>
</NRadioGroup>
</FormField>
<FormField v-if="ipType === 'reserved'" label="选择保留 IP">
<NSelect
v-model:value="reservedId"
:options="options"
:loading="reservedIps.loading.value"
:render-label="renderReservedIpLabel"
:consistent-menu-width="false"
placeholder="选择未绑定的保留 IP"
clearable
/>
<div v-if="!reservedIps.loading.value && !options.length" class="mt-1 text-xs text-ink-3">
无可用保留 IP,可在网络 保留 IP面板创建
</div>
</FormField>
</FormModal>
</template>
@@ -44,6 +44,11 @@ const canSubmit = computed(
async function submit() {
if (props.cfgId === null || !specForm.value) return
const spec = specForm.value.buildSpec()
if (spec.reservedPublicIpId && form.count > 1) {
message.warning('保留 IP 仅支持单台创建,请把数量改为 1')
return
}
submitting.value = true
try {
const result = await createInstances(props.cfgId, {
@@ -51,7 +56,7 @@ async function submit() {
compartmentId: props.compartmentId || undefined,
displayName: form.displayName.trim(),
count: form.count,
...specForm.value.buildSpec(),
...spec,
})
// 后端 errors / instances 为空时序列化为 null,判空防炸
const ok = result.instances?.length ?? 0
+53 -2
View File
@@ -4,6 +4,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { listAvailabilityDomains, listImages, listShapes } from '@/api/instances'
import { listSubnets, listVcns } from '@/api/networks'
import { listReservedIps } from '@/api/reservedips'
import { freeReservedIpOptions, renderReservedIpLabel } from '@/components/network/reservedIpSelect'
import { listLimits } from '@/api/tenant'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
@@ -42,6 +44,8 @@ const blank = {
bootVolumeSizeGBs: null as number | null,
bootVolumeVpusPerGB: 10,
assignPublicIp: true,
publicIpMode: 'ephemeral' as 'ephemeral' | 'reserved' | 'none',
reservedPublicIpId: null as string | null,
assignIpv6: true,
authMode: 'password' as 'password' | 'ssh',
randomPassword: true,
@@ -173,6 +177,12 @@ function applySpec(spec: Record<string, unknown>) {
if (typeof s.bootVolumeSizeGBs === 'number') form.bootVolumeSizeGBs = s.bootVolumeSizeGBs
if (typeof s.bootVolumeVpusPerGB === 'number') form.bootVolumeVpusPerGB = s.bootVolumeVpusPerGB
form.assignPublicIp = s.assignPublicIp !== false
if (typeof s.reservedPublicIpId === 'string' && s.reservedPublicIpId) {
form.publicIpMode = 'reserved'
form.reservedPublicIpId = s.reservedPublicIpId
} else {
form.publicIpMode = s.assignPublicIp !== false ? 'ephemeral' : 'none'
}
form.assignIpv6 = s.assignIpv6 !== false
form.authMode = typeof s.sshPublicKey === 'string' && s.sshPublicKey ? 'ssh' : 'password'
form.sshPublicKey = typeof s.sshPublicKey === 'string' ? s.sshPublicKey : ''
@@ -376,9 +386,33 @@ watch(ipv6Supported, (v) => {
const valid = computed(() => {
if (!form.imageId) return false
if (form.vcnId !== AUTO_VCN && !form.subnetId) return false
if (form.publicIpMode === 'reserved' && !form.reservedPublicIpId) return false
return !isFlex.value || form.ocpus > 0
})
// ---- 公网 IPv4 分配方式:临时 / 保留 / 不分配 ----
const publicIpModes = [
{ label: '临时 IP', value: 'ephemeral' },
{ label: '保留 IP', value: 'reserved' },
{ label: '不分配', value: 'none' },
]
const reservedIps = useAsync(async () => {
if (props.cfgId === null) return []
try {
return await listReservedIps(props.cfgId, props.region || undefined, props.compartmentId || undefined)
} catch {
return []
}
}, false)
const freeReservedOptions = computed(() => freeReservedIpOptions(reservedIps.data.value ?? []))
watch(
() => form.publicIpMode,
(mode) => {
if (mode === 'reserved') void reservedIps.run()
else form.reservedPublicIpId = null
},
)
/** 规格摘要(父表单底部预估条用):shape 短名 + 弹性核内存 */
const summary = computed(() => {
const parts = [form.shape.replace(/^VM\.Standard\.?/, '') || form.shape]
@@ -399,7 +433,9 @@ function buildSpec(): Record<string, unknown> {
bootVolumeSizeGBs: form.bootVolumeSizeGBs ?? undefined,
// 性能独立于大小:留空按镜像默认大小时同样生效(后端仅镜像启动源使用)
bootVolumeVpusPerGB: form.bootVolumeVpusPerGB || undefined,
assignPublicIp: form.assignPublicIp,
assignPublicIp: form.publicIpMode === 'ephemeral',
reservedPublicIpId:
form.publicIpMode === 'reserved' ? (form.reservedPublicIpId ?? undefined) : undefined,
assignIpv6: form.assignIpv6,
generateRootPassword: usePwd && form.randomPassword ? true : undefined,
rootPassword:
@@ -578,8 +614,23 @@ defineExpose({ valid, buildSpec, reset, applySpec, summary })
placeholder="ssh-ed25519 AAAA…"
/>
</FormField>
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<FormField label="公网 IPv4" hint="保留 IP:实例就绪后自动绑定,期间短暂无公网地址;仅单台创建可用">
<NSelect v-model:value="form.publicIpMode" :options="publicIpModes" />
</FormField>
<FormField v-if="form.publicIpMode === 'reserved'" label="保留 IP" required>
<NSelect
v-model:value="form.reservedPublicIpId"
:options="freeReservedOptions"
:loading="reservedIps.loading.value"
:render-label="renderReservedIpLabel"
:consistent-menu-width="false"
placeholder="选择未绑定的保留 IP"
filterable
/>
</FormField>
</div>
<div class="flex flex-wrap gap-5">
<NCheckbox v-model:checked="form.assignPublicIp">分配公网 IPv4</NCheckbox>
<NCheckbox v-model:checked="form.assignIpv6" :disabled="!ipv6Supported">
分配 IPv6{{ ipv6Supported ? '' : '(所选子网未启用)' }}
</NCheckbox>
+80 -9
View File
@@ -1,16 +1,19 @@
<script setup lang="ts">
import { NButton, NPopconfirm, NSpin } from 'naive-ui'
import { NButton, NSpin } from 'naive-ui'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { addVnicIpv6, detachVnic, listVnics, removeIpv6 } from '@/api/instances'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FootNote from '@/components/FootNote.vue'
import AttachVnicModal from '@/components/instance/AttachVnicModal.vue'
import ChangeIpModal from '@/components/instance/ChangeIpModal.vue'
import LifecycleBadge from '@/components/LifecycleBadge.vue'
import OcidText from '@/components/OcidText.vue'
import { useAsync } from '@/composables/useAsync'
import { ATTACHMENT_TRANSIENT_STATES, useTransientPoll } from '@/composables/useTransientPoll'
import { useToast } from '@/composables/useToast'
import type { Vnic } from '@/types/api'
/** 网卡(VNIC)区块:每卡一主行 + IPv6 副行;IPv6 增删按卡操作。
* 删除复用实例级接口(后端按地址遍历全部网卡),添加走 per-VNIC 接口。 */
@@ -35,10 +38,46 @@ useTransientPoll(
() => vnics.data.value,
(list) =>
!!list?.some((v) => ATTACHMENT_TRANSIENT_STATES.includes(v.lifecycleState)) ||
((list?.length ?? 0) < expectAtLeast.value && Date.now() < expectUntil),
((list?.length ?? 0) < expectAtLeast.value && Date.now() < expectUntil) ||
// 已 ATTACHED 但公网 IP 尚未回填(临时分配 / 保留 IP 后台绑定滞后),窗口期内继续轮询
(Date.now() < expectUntil &&
!!list?.some((v) => v.lifecycleState === 'ATTACHED' && !v.publicIp)) ||
ipStale(list),
() => void vnics.run({ silent: true }),
)
// ---- 换 IP 收敛:OCI GetVnic 公网 IP 有秒级回填延迟,立即重查拿到的仍是旧值;
// 换 IP 成功后开 90s 窗口轮询,直到目标网卡 IP 更新,再通知父级刷实例详情顶卡 ----
const ipExpect = ref<{ vnicId: string; newIp?: string; oldIp?: string; until: number } | null>(null)
/** 目标网卡公网 IP 尚未刷新到预期值:窗口内视为过渡态继续轮询 */
function ipStale(list: Vnic[] | null | undefined): boolean {
const e = ipExpect.value
if (!e || Date.now() > e.until) return false
const v = (list ?? []).find((x) => x.vnicId === e.vnicId)
if (!v) return false
return e.newIp ? v.publicIp !== e.newIp : v.publicIp === e.oldIp
}
function onIpChanged(p: { vnicId: string; newIp?: string }) {
ipExpect.value = { ...p, oldIp: changeIpTarget.value.publicIp, until: Date.now() + 90_000 }
refresh()
}
// 收敛(或窗口过期)后清理并再通知父级:此刻数据源已是新 IP,顶卡才能刷到新值
watch(
() => vnics.data.value,
(list) => {
const e = ipExpect.value
if (!e) return
const expired = Date.now() > e.until
if (expired || !ipStale(list)) {
ipExpect.value = null
if (!expired) emit('changed')
}
},
)
const showAttach = ref(false)
const detaching = ref('')
const addingIpv6 = ref('')
@@ -56,6 +95,15 @@ function onAttached() {
refresh()
}
// ---- 换公网 IP:主卡/次卡统一走弹窗(可选临时或保留 IP) ----
const showChangeIp = ref(false)
const changeIpTarget = ref<{ vnicId: string; publicIp?: string }>({ vnicId: '' })
function openChangeIp(vnicId: string, publicIp?: string) {
changeIpTarget.value = { vnicId, publicIp }
showChangeIp.value = true
}
async function doDetach(attachmentId: string) {
detaching.value = attachmentId
try {
@@ -127,7 +175,7 @@ defineExpose({ refresh: () => vnics.run() })
<th class="border-b border-line-soft px-3 py-2 font-medium">公网 IP</th>
<th class="border-b border-line-soft px-3 py-2 font-medium">子网</th>
<th class="w-28 border-b border-line-soft px-3 py-2 font-medium">状态</th>
<th class="w-18 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
<th class="w-30 border-b border-line-soft py-2 pr-4.5 pl-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
@@ -156,30 +204,44 @@ defineExpose({ refresh: () => vnics.run() })
<span v-else></span>
</td>
<td class="px-3 py-2.5 pb-1.5"><LifecycleBadge :state="v.lifecycleState" /></td>
<td class="py-2.5 pr-4.5 pb-1.5 pl-3">
<td class="py-2.5 pr-4.5 pb-1.5 pl-3 whitespace-nowrap">
<NButton
size="tiny"
quaternary
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || !v.vnicId || busy"
@click="openChangeIp(v.vnicId, v.publicIp)"
>
IP
</NButton>
<NButton
v-if="v.isPrimary"
size="tiny"
quaternary
disabled
title="主网卡不可分离"
class="ml-1"
>
分离
</NButton>
<NPopconfirm v-else @positive-click="doDetach(v.attachmentId)">
<ConfirmPop
v-else
title="分离该网卡?"
content="其上全部 IP(含 IPv6)将一并释放。"
confirm-text="分离"
:on-confirm="() => doDetach(v.attachmentId)"
>
<template #trigger>
<NButton
size="tiny"
quaternary
type="error"
:loading="detaching === v.attachmentId"
class="ml-1"
:disabled="disabled || v.lifecycleState !== 'ATTACHED' || busy"
>
分离
</NButton>
</template>
分离该网卡其上全部 IP IPv6将一并释放
</NPopconfirm>
</ConfirmPop>
</td>
</tr>
<tr class="border-b border-line-soft last:border-b-0">
@@ -246,5 +308,14 @@ defineExpose({ refresh: () => vnics.run() })
:attached-count="(vnics.data.value ?? []).length"
@attached="onAttached"
/>
<ChangeIpModal
v-model:show="showChangeIp"
:cfg-id="cfgId"
:region="region"
:vnic-id="changeIpTarget.vnicId"
:current-ip="changeIpTarget.publicIp"
@changed="onIpChanged"
/>
</div>
</template>
+5 -4
View File
@@ -5,6 +5,7 @@ import { computed, h, onMounted, reactive, ref } from 'vue'
import { listAiCallLogs, listAiContentLogs } from '@/api/aigateway'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { fmtTime } from '@/composables/useFormat'
@@ -194,10 +195,10 @@ const footerText = computed(() => {
<template>
<div class="panel">
<div class="border-b border-line-soft px-4.5 py-3">
<div class="text-sm font-semibold">AI 调用日志</div>
<div class="mt-0.5 text-xs text-ink-3">仅元数据与 token 用量,不记录对话内容;保留 90 / 5 万行 · 5 秒自动刷新</div>
</div>
<PanelHeader
title="AI 调用日志"
desc="仅元数据与 token 用量,不记录对话内容;保留 90 天 / 5 万行 · 每 5 秒自动刷新"
/>
<NDataTable
remote
:columns="columns"
+5 -11
View File
@@ -7,7 +7,6 @@ import { listConfigs } from '@/api/configs'
import { listLogEvents } from '@/api/logevents'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TenantPicker from '@/components/TenantPicker.vue'
@@ -167,9 +166,10 @@ const columns: DataTableColumns<LogEvent> = [
render: renderTenant,
},
{
// 固定宽:常见事件类型约 45 字符,弹性空间让给来源列
title: '事件类型',
key: 'eventType',
minWidth: 280,
width: 400,
ellipsis: { tooltip: true },
render: (row) =>
row.eventType
@@ -186,9 +186,10 @@ const columns: DataTableColumns<LogEvent> = [
: h('span', { class: 'text-[13px] text-ink-3' }, '—'),
},
{
// 弹性列:实例名可较长,宽屏下尽量完整展示
title: '来源',
key: 'source',
width: 160,
minWidth: 160,
ellipsis: { tooltip: true },
render: (row) => h('span', { class: 'text-[13px] text-ink-2' }, row.source || '—'),
},
@@ -227,6 +228,7 @@ useAutoRefresh(() => load(true))
clearable
placeholder="全部租户"
class="w-52"
placement="bottom-end"
:configs="scope.configs.data ?? []"
:loading="scope.configs.loading"
@update:value="onFilter"
@@ -243,14 +245,6 @@ useAutoRefresh(() => load(true))
:row-key="(r: LogEvent) => r.id"
:row-props="rowProps"
/>
<FootNote>
在租户详情「其他」tab 一键创建链路后,实例终止与电源操作(Terminate/InstanceAction;
创建不回传,避免面板抢机与批量创建刷屏)、用户与凭据(Create/Delete/UpdateUser、
Create/DeleteApiKey、UpdateUserCapabilities)、区域订阅与策略变更等关键审计事件将自动回传;
消息按 MessageId 幂等去重,保留 90 天、总量超 2 万条时删除最旧,关键事件另经
「通知管理 → 云端事件」推送告警
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '720px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
+1 -8
View File
@@ -5,7 +5,6 @@ import { computed, h, reactive, ref } from 'vue'
import { listSystemLogs } from '@/api/settings'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { actionLabel } from '@/composables/useActionLabel'
@@ -173,7 +172,7 @@ useAutoRefresh(() => load(true))
size="small"
clearable
placeholder="搜索路径 / 用户名,回车确认"
class="max-w-60"
class="!max-w-64"
@keyup.enter="search"
@clear="onClear"
/>
@@ -188,12 +187,6 @@ useAutoRefresh(() => load(true))
:row-key="(r: SystemLog) => r.id"
:row-props="rowProps"
/>
<FootNote>
自动记录 secured 接口的全部写请求(POST / PUT /
DELETE)与登录成败,只存方法与路径等元数据、绝不记录请求体;失败请求另存响应错误文案与
User-Agent;日志保留 90 天,总量超 5 万条时自动删除最旧记录 · 点击行查看详情
</FootNote>
<NModal v-model:show="showDetail" preset="card" closable :style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }">
<template #header>
<DetailHero
+247
View File
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { NButton, NDataTable, NModal, NSelect, type DataTableColumns } from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { listInstances } from '@/api/instances'
import { assignReservedIp, createReservedIp, deleteReservedIp, listReservedIps } from '@/api/reservedips'
import ConfirmPop from '@/components/ConfirmPop.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { ReservedIp } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number | null; region?: string; compartmentId?: string }>()
const message = useToast()
const ips = useAsync<ReservedIp[]>(async () => {
if (!props.cfgId) return []
try {
return await listReservedIps(props.cfgId, props.region, props.compartmentId)
} catch {
return []
}
}, false)
watch([() => props.cfgId, () => props.region, () => props.compartmentId], () => void ips.run(), {
immediate: true,
})
const creating = ref(false)
async function create() {
if (!props.cfgId) return
creating.value = true
try {
await createReservedIp(props.cfgId, props.region, undefined, props.compartmentId)
message.success('保留 IP 已创建')
void ips.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
creating.value = false
}
}
// ---- 绑定实例弹窗 ----
const showBind = ref(false)
const bindTarget = ref<ReservedIp | null>(null)
const bindInstanceId = ref<string | null>(null)
const binding = ref(false)
const instances = useAsync(async () => {
if (!props.cfgId) return []
try {
return await listInstances(props.cfgId, props.region, props.compartmentId)
} catch {
return []
}
}, false)
const instanceOptions = computed(() =>
(instances.data.value ?? [])
.filter((i) => !['TERMINATED', 'TERMINATING'].includes(i.lifecycleState))
.map((i) => ({ label: `${i.displayName}(${i.publicIp || '无公网 IP'})`, value: i.id })),
)
function openBind(row: ReservedIp) {
bindTarget.value = row
bindInstanceId.value = null
showBind.value = true
void instances.run()
}
async function doBind() {
if (!props.cfgId || !bindTarget.value || !bindInstanceId.value) return
binding.value = true
try {
await assignReservedIp(props.cfgId, bindTarget.value.id, bindInstanceId.value, props.region)
message.success('已绑定;实例原临时公网 IP 已释放')
showBind.value = false
void ips.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '绑定失败')
} finally {
binding.value = false
}
}
async function unbind(row: ReservedIp) {
if (!props.cfgId) return
try {
await assignReservedIp(props.cfgId, row.id, '', props.region)
message.success('已解绑,实例暂无公网 IP')
void ips.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '解绑失败')
}
}
async function remove(row: ReservedIp) {
if (!props.cfgId) return
try {
await deleteReservedIp(props.cfgId, row.id, props.region)
message.success('保留 IP 已删除')
void ips.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
const columns = computed<DataTableColumns<ReservedIp>>(() => [
{
title: 'IP 地址',
key: 'ipAddress',
minWidth: 140,
render: (r) => h('span', { class: 'mono' }, r.ipAddress || '分配中…'),
},
{
title: '名称',
key: 'displayName',
minWidth: 130,
render: (r) => h('span', { class: 'text-[13px]' }, r.displayName),
},
{
title: '状态',
key: 'assigned',
minWidth: 110,
render: (r) =>
r.assignedInstanceId
? h(StatusBadge, { kind: 'run', label: '已绑定', dot: false })
: h(StatusBadge, { kind: 'check', label: '未绑定', dot: false }),
},
{
title: '绑定实例',
key: 'assignedInstanceName',
minWidth: 150,
render: (r) =>
h(
'span',
{ class: 'text-[13px] text-ink-2' },
r.assignedInstanceName || (r.assignedInstanceId ? r.assignedInstanceId.slice(-8) : '—'),
),
},
{
title: '创建时间',
key: 'timeCreated',
width: 150,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
},
{
title: '操作',
key: 'actions',
width: 150,
render: (r) =>
h('div', { class: 'flex items-center gap-1' }, [
r.assignedInstanceId
? h(
ConfirmPop,
{
title: '解绑该保留 IP',
content: '解绑后实例将暂无公网 IP,直到重新绑定或分配临时 IP。',
kind: 'plain',
confirmText: '解绑',
onConfirm: () => unbind(r),
},
{ trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '解绑' }) },
)
: h(
NButton,
{ size: 'tiny', quaternary: true, onClick: () => openBind(r) },
{ default: () => '绑定实例' },
),
h(
ConfirmPop,
{
title: '删除该保留 IP',
content: '地址将归还 Oracle,无法找回。',
confirmText: '删除',
onConfirm: () => remove(r),
},
{
trigger: () =>
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error', disabled: !!r.assignedInstanceId },
{ default: () => '删除' },
),
},
),
]),
},
])
</script>
<template>
<div class="panel">
<div class="flex items-center justify-between 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,创建实例时也可直接选用
</div>
</div>
<NButton size="small" type="primary" :loading="creating" @click="create">创建保留 IP</NButton>
</div>
<NDataTable
:columns="columns"
:data="ips.data.value ?? []"
:loading="ips.loading.value"
:scroll-x="820"
:row-key="(r: ReservedIp) => r.id"
/>
<NModal
v-model:show="showBind"
preset="card"
title="绑定保留 IP 到实例"
:style="{ width: '420px', maxWidth: 'calc(100vw - 24px)' }"
>
<div class="flex flex-col gap-3">
<div class="text-[13px] text-ink-2">
将 <span class="mono">{{ bindTarget?.ipAddress }}</span> 绑定到实例主网卡;
实例现有临时公网 IP 会被释放,公网地址变更为该保留 IP。
</div>
<NSelect
v-model:value="bindInstanceId"
:options="instanceOptions"
:loading="instances.loading.value"
placeholder="选择实例"
filterable
/>
<div class="flex justify-end gap-2">
<NButton size="small" @click="showBind = false">取消</NButton>
<NButton
size="small"
type="primary"
:disabled="!bindInstanceId"
:loading="binding"
@click="doBind"
>
绑定
</NButton>
</div>
</div>
</NModal>
</div>
</template>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { NButton, NInput, NInputNumber, NSelect } from 'naive-ui'
import { reactive, ref } from 'vue'
import { updateSecurityList } from '@/api/networks'
import ConfirmPop from '@/components/ConfirmPop.vue'
import type { SecurityList, SecurityRule } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
cfgId: number
list: SecurityList
region?: string
}>()
const emit = defineEmits<{ updated: [] }>()
const message = useToast()
type Dir = 'ingress' | 'egress'
const PROTOCOLS = [
{ label: '全部', value: 'all' },
{ label: 'TCP', value: '6' },
{ label: 'UDP', value: '17' },
{ label: 'ICMP', value: '1' },
{ label: 'ICMPv6', value: '58' },
]
function protoLabel(p: string): string {
return PROTOCOLS.find((o) => o.value === p)?.label ?? p
}
function portText(r: SecurityRule): string {
if (r.protocol !== '6' && r.protocol !== '17') return '—'
if (!r.portMin) return '全部'
return r.portMax && r.portMax !== r.portMin ? `${r.portMin}-${r.portMax}` : String(r.portMin)
}
/** 编辑缓冲;idx 为 -1 表示新增行 */
const editing = ref<{ dir: Dir; idx: number } | null>(null)
const draft = reactive<SecurityRule>({ protocol: '6', isStateless: false })
const saving = ref(false)
function rulesOf(dir: Dir): SecurityRule[] {
return dir === 'ingress' ? (props.list.ingressRules ?? []) : (props.list.egressRules ?? [])
}
function startEdit(dir: Dir, idx: number) {
const src = idx >= 0 ? rulesOf(dir)[idx] : undefined
Object.assign(draft, {
protocol: src?.protocol ?? '6',
source: src?.source,
destination: src?.destination,
isStateless: src?.isStateless ?? false,
portMin: src?.portMin,
portMax: src?.portMax,
description: src?.description,
})
if (!src) {
if (dir === 'ingress') draft.source = '0.0.0.0/0'
else draft.destination = '0.0.0.0/0'
}
editing.value = { dir, idx }
}
function buildDraft(dir: Dir): SecurityRule {
const r: SecurityRule = { protocol: draft.protocol, isStateless: false }
if (dir === 'ingress') r.source = draft.source?.trim()
else r.destination = draft.destination?.trim()
if (draft.protocol === '6' || draft.protocol === '17') {
if (draft.portMin) {
r.portMin = draft.portMin
r.portMax = draft.portMax || draft.portMin
}
}
if (draft.description?.trim()) r.description = draft.description.trim()
return r
}
/** 整表替换提交:规则字段一经提供即全量覆盖,两方向都要带上 */
async function submit(ingress: SecurityRule[], egress: SecurityRule[]) {
saving.value = true
try {
await updateSecurityList(props.cfgId, props.list.id, {
region: props.region,
ingressRules: ingress,
egressRules: egress,
})
message.success('规则已保存')
editing.value = null
emit('updated')
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败')
} finally {
saving.value = false
}
}
function saveEditing() {
const ed = editing.value
if (!ed) return
const cidr = ed.dir === 'ingress' ? draft.source : draft.destination
if (!cidr?.trim()) {
message.warning('CIDR 不能为空')
return
}
const next = rulesOf(ed.dir).slice()
const rule = buildDraft(ed.dir)
if (ed.idx >= 0) next[ed.idx] = rule
else next.push(rule)
const ing = ed.dir === 'ingress' ? next : (props.list.ingressRules ?? [])
const eg = ed.dir === 'egress' ? next : (props.list.egressRules ?? [])
void submit(ing, eg)
}
function removeRule(dir: Dir, idx: number): Promise<void> {
const next = rulesOf(dir).filter((_, i) => i !== idx)
const ing = dir === 'ingress' ? next : (props.list.ingressRules ?? [])
const eg = dir === 'egress' ? next : (props.list.egressRules ?? [])
return submit(ing, eg)
}
function isEditing(dir: Dir, idx: number): boolean {
return editing.value?.dir === dir && editing.value.idx === idx
}
</script>
<template>
<div class="flex flex-col gap-3 py-1">
<div v-for="dir in ['ingress', 'egress'] as const" :key="dir">
<div class="mb-1.5 text-[11.5px] font-semibold tracking-wide text-ink-3 uppercase">
{{ dir === 'ingress' ? '入站规则' : '出站规则' }}
</div>
<div class="overflow-hidden rounded-lg border border-line-soft bg-white">
<table class="w-full border-collapse text-[12.5px]">
<thead>
<tr class="text-left text-xs text-ink-3">
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-28">协议</th>
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-44">
{{ dir === 'ingress' ? '源' : '目标' }}
</th>
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold w-36">端口</th>
<th class="border-b border-line-soft px-3.5 py-1.5 font-semibold">描述</th>
<th class="border-b border-line-soft px-3.5 py-1.5 text-right font-semibold w-24">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(r, idx) in rulesOf(dir)" :key="idx" :class="isEditing(dir, idx) ? 'bg-wash/60' : ''">
<template v-if="isEditing(dir, idx)">
<td class="border-b border-line-soft px-3.5 py-1.5">
<NSelect
v-model:value="draft.protocol"
size="tiny"
:options="PROTOCOLS"
class="!w-24"
:consistent-menu-width="false"
/>
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<NInput
v-if="dir === 'ingress'"
v-model:value="draft.source"
size="tiny"
placeholder="0.0.0.0/0"
/>
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<div
v-if="draft.protocol === '6' || draft.protocol === '17'"
class="flex items-center gap-1"
>
<NInputNumber
v-model:value="draft.portMin"
size="tiny"
:show-button="false"
placeholder="起"
class="!w-16"
/>
<span class="text-ink-3">-</span>
<NInputNumber
v-model:value="draft.portMax"
size="tiny"
:show-button="false"
placeholder="止"
class="!w-16"
/>
</div>
<span v-else class="text-ink-3"></span>
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
</td>
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
取消
</NButton>
</td>
</template>
<template v-else>
<td class="border-b border-line-soft px-3.5 py-1.5">{{ protoLabel(r.protocol) }}</td>
<td class="mono border-b border-line-soft px-3.5 py-1.5">
{{ dir === 'ingress' ? r.source : r.destination }}
</td>
<td class="mono border-b border-line-soft px-3.5 py-1.5">{{ portText(r) }}</td>
<td class="border-b border-line-soft px-3.5 py-1.5 text-ink-2">{{ r.description || '—' }}</td>
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
<NButton size="tiny" quaternary :disabled="!!editing" @click="startEdit(dir, idx)">
编辑
</NButton>
<ConfirmPop
title="删除该条规则?"
:content="`${protoLabel(r.protocol)} ${dir === 'ingress' ? r.source : r.destination} ${portText(r)}`"
:on-confirm="() => removeRule(dir, idx)"
>
<template #trigger>
<NButton size="tiny" quaternary type="error" class="ml-1" :disabled="!!editing">
删除
</NButton>
</template>
</ConfirmPop>
</td>
</template>
</tr>
<tr v-if="editing && editing.dir === dir && editing.idx === -1" class="bg-wash/60">
<td class="border-b border-line-soft px-3.5 py-1.5">
<NSelect
v-model:value="draft.protocol"
size="tiny"
:options="PROTOCOLS"
class="!w-24"
:consistent-menu-width="false"
/>
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<NInput
v-if="dir === 'ingress'"
v-model:value="draft.source"
size="tiny"
placeholder="0.0.0.0/0"
/>
<NInput v-else v-model:value="draft.destination" size="tiny" placeholder="0.0.0.0/0" />
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<div
v-if="draft.protocol === '6' || draft.protocol === '17'"
class="flex items-center gap-1"
>
<NInputNumber
v-model:value="draft.portMin"
size="tiny"
:show-button="false"
placeholder=""
class="!w-16"
/>
<span class="text-ink-3">-</span>
<NInputNumber
v-model:value="draft.portMax"
size="tiny"
:show-button="false"
placeholder=""
class="!w-16"
/>
</div>
<span v-else class="text-ink-3">—</span>
</td>
<td class="border-b border-line-soft px-3.5 py-1.5">
<NInput v-model:value="draft.description" size="tiny" placeholder="描述(可选)" />
</td>
<td class="border-b border-line-soft px-3.5 py-1.5 text-right whitespace-nowrap">
<NButton size="tiny" type="primary" :loading="saving" @click="saveEditing">保存</NButton>
<NButton size="tiny" quaternary class="ml-1" :disabled="saving" @click="editing = null">
取消
</NButton>
</td>
</tr>
<tr>
<td colspan="5" class="px-3.5 py-1.5">
<NButton
size="tiny"
dashed
:disabled="!!editing"
@click="startEdit(dir, -1)"
>
+ 添加{{ dir === 'ingress' ? '入站' : '出站' }}规则
</NButton>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
@@ -0,0 +1,26 @@
import type { SelectOption } from 'naive-ui'
import { h, type VNodeChild } from 'vue'
import type { ReservedIp } from '@/types/api'
export interface ReservedIpOption extends SelectOption {
label: string
value: string
name: string
}
/** 未绑定保留 IP → 下拉选项:label 仅 IP(回显紧凑不截断),名称在选项行内弱化展示 */
export function freeReservedIpOptions(ips: ReservedIp[]): ReservedIpOption[] {
return ips
.filter((r) => !r.assignedInstanceId)
.map((r) => ({ label: r.ipAddress || r.id.slice(-12), value: r.id, name: r.displayName }))
}
/** NSelect renderLabel:IP 等宽字体 + 名称灰字截断;配合 consistent-menu-width=false 使用 */
export function renderReservedIpLabel(option: SelectOption): VNodeChild {
const o = option as ReservedIpOption
return h('div', { class: 'flex min-w-0 items-baseline gap-2' }, [
h('span', { class: 'mono text-[13px]' }, o.label),
o.name ? h('span', { class: 'max-w-44 truncate text-xs text-ink-3' }, o.name) : null,
])
}
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from 'vue'
import { extOf } from '@/components/objectstorage/codeHighlight'
import { FILE_ICON_BODIES } from '@/components/objectstorage/fileIcons'
const props = defineProps<{ name?: string; folder?: boolean; size?: number }>()
/** 扩展名 → Catppuccin 图标名;不在表内用通用 file */
const EXT_ICON: Record<string, string> = {
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image',
svg: 'image', bmp: 'image', ico: 'image', avif: 'image',
json: 'json',
yaml: 'yaml', yml: 'yaml',
toml: 'toml',
ini: 'properties', conf: 'properties', cfg: 'properties', env: 'properties', properties: 'properties',
py: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript',
ts: 'typescript',
md: 'markdown',
sql: 'database', db: 'database', sqlite: 'database',
sh: 'bash', bash: 'bash', zsh: 'bash',
go: 'go',
zip: 'zip', tar: 'zip', gz: 'zip', tgz: 'zip', '7z': 'zip', rar: 'zip', bz2: 'zip', xz: 'zip',
pdf: 'pdf',
mp4: 'video', mov: 'video', mkv: 'video', avi: 'video', webm: 'video',
mp3: 'audio', wav: 'audio', flac: 'audio', ogg: 'audio', m4a: 'audio',
html: 'html', htm: 'html',
css: 'css', scss: 'css', less: 'css',
txt: 'text',
log: 'log',
csv: 'csv', tsv: 'csv',
xml: 'xml',
bin: 'binary', exe: 'binary', dmg: 'binary', iso: 'binary', img: 'binary', qcow2: 'binary',
doc: 'ms-word', docx: 'ms-word',
xls: 'ms-excel', xlsx: 'ms-excel',
ppt: 'ms-powerpoint', pptx: 'ms-powerpoint',
}
const body = computed(() => {
if (props.folder) return FILE_ICON_BODIES.folder
const key = EXT_ICON[extOf(props.name ?? '')] ?? 'file'
return FILE_ICON_BODIES[key] ?? FILE_ICON_BODIES.file
})
</script>
<template>
<!-- eslint-disable vue/no-v-html -- 图标 body 为构建期静态常量(Catppuccin Icons),非用户输入 -->
<svg
:width="size ?? 16"
:height="size ?? 16"
viewBox="0 0 16 16"
class="inline-block flex-none align-[-3px]"
aria-hidden="true"
v-html="body"
></svg>
<!-- eslint-enable vue/no-v-html -->
</template>
@@ -0,0 +1,660 @@
<script setup lang="ts">
import {
NButton,
NDataTable,
NDropdown,
NInput,
NProgress,
NRadio,
NRadioGroup,
type DataTableColumns,
} from 'naive-ui'
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'
import {
createPar,
deleteObject,
deletePar,
listObjects,
restoreObject,
uploadViaPar,
} from '@/api/objectstorage'
import AppInputNumber from '@/components/AppInputNumber.vue'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FileIcon from '@/components/objectstorage/FileIcon.vue'
import ObjectViewerModal from '@/components/objectstorage/ObjectViewerModal.vue'
import ParPanel from '@/components/objectstorage/ParPanel.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { 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
}>()
const emit = defineEmits<{ back: [] }>()
const message = useToast()
const PAGE_SIZE = 100
const prefix = ref('')
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
const filterInput = ref('')
/** 上页游标栈:OCI 只回传 next 游标,上一页靠历史栈回放 */
const cursorStack = ref<string[]>([])
const cursor = ref('')
const result = useAsync(async () => {
if (!props.cfgId) return { objects: [], prefixes: [], nextStartWith: '' }
return listObjects(props.cfgId, props.bucket, {
region: props.region,
prefix: prefix.value,
startWith: cursor.value || undefined,
limit: PAGE_SIZE,
})
}, false)
watch([() => props.cfgId, () => props.bucket, prefix, cursor], () => void result.run(), {
immediate: true,
})
/** 有对象取回中(Restoring)时后台静默轮询,状态落定自动停 */
const POLL_MS = 30_000
let pollTimer = 0
watch(
() => (result.data.value?.objects ?? []).some((o) => o.archivalState === 'Restoring'),
(has) => {
window.clearInterval(pollTimer)
if (has) pollTimer = window.setInterval(() => void result.run({ silent: true }), POLL_MS)
},
)
onBeforeUnmount(() => window.clearInterval(pollTimer))
function enterFolder(p: string) {
cursorStack.value = []
cursor.value = ''
prefix.value = p
filterInput.value = ''
}
/** 面包屑段:桶根 + prefix 逐级 */
const crumbs = computed(() => {
const parts = prefix.value.split('/').filter(Boolean)
return parts.map((seg, i) => ({ label: `${seg}/`, prefix: parts.slice(0, i + 1).join('/') + '/' }))
})
/** 层级过深时仅展示末 2 段,其余收进「…」下拉,避免面包屑占满整行 */
const visibleCrumbs = computed(() =>
crumbs.value.length > 3 ? crumbs.value.slice(-2) : crumbs.value,
)
const foldedOptions = computed(() =>
crumbs.value.length > 3
? crumbs.value.slice(0, -2).map((c) => ({ label: c.prefix, key: c.prefix }))
: [],
)
function nextPage() {
const next = result.data.value?.nextStartWith
if (!next) return
cursorStack.value.push(cursor.value)
cursor.value = next
}
function prevPage() {
cursor.value = cursorStack.value.pop() ?? ''
}
// ---- 上传:PAR 直传,逐文件进度 ----
interface UploadTask {
name: string
percent: number
error?: string
abort: () => void
}
const uploads = ref<UploadTask[]>([])
const fileInput = ref<HTMLInputElement | null>(null)
function pickFiles() {
fileInput.value?.click()
}
async function onFilesPicked(e: Event) {
const files = [...((e.target as HTMLInputElement).files ?? [])]
;(e.target as HTMLInputElement).value = ''
await Promise.all(files.map((f) => uploadOne(f)))
void result.run({ silent: true })
}
async function uploadOne(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, {
region: props.region,
objectName,
accessType: 'ObjectWrite',
expiresHours: 1,
})
parId = par.id
const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
task.abort = abort
await promise
uploads.value = uploads.value.filter((t) => t !== task)
message.success(`${file.name} 上传完成`)
} catch (err) {
task.error = err instanceof Error ? err.message : '上传失败'
} finally {
if (parId) void cleanupPar(parId)
}
}
/** 上传用的一次性写链接用后即删,不留在分享链接列表;删除失败静默(1 小时自然过期) */
async function cleanupPar(parId: string) {
try {
await deletePar(props.cfgId!, props.bucket, parId, props.region)
} catch {
/* 忽略 */
} finally {
parPanel.value?.refresh()
}
}
function dismissUpload(task: UploadTask) {
task.abort()
uploads.value = uploads.value.filter((t) => t !== task)
}
// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
const showNewFolder = ref(false)
const folderName = ref('')
const creatingFolder = ref(false)
async function doCreateFolder() {
if (!props.cfgId || !folderName.value.trim()) return
creatingFolder.value = true
let parId = ''
try {
const name = prefix.value + folderName.value.trim().replace(/\/+$/, '') + '/'
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: name,
accessType: 'ObjectWrite',
expiresHours: 1,
})
parId = par.id
await uploadViaPar(par.fullUrl!, new File([], name), () => {}).promise
message.success('文件夹已创建')
showNewFolder.value = false
folderName.value = ''
void result.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败')
} finally {
if (parId) void cleanupPar(parId)
creatingFolder.value = false
}
}
// ---- 行操作 ----
async function 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 {
await deleteObject(props.cfgId, props.bucket, name, props.region)
message.success('对象已删除')
checkedKeys.value = checkedKeys.value.filter((k) => k !== name)
void result.run()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
async function restore(name: string) {
if (!props.cfgId) return
try {
await restoreObject(props.cfgId, props.bucket, name, props.region)
message.success('取回已提交,约 1 小时后可下载 (24 小时有效)')
// 立刻刷出 Restoring 态,触发后台轮询
void result.run({ silent: true })
} catch (e) {
message.error(e instanceof Error ? e.message : '取回失败')
}
}
// ---- 临时链接签发 ----
const showPar = ref(false)
const parBusy = ref(false)
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
objectName: '',
accessType: 'ObjectRead',
expiresHours: 24,
})
const parUrl = ref('')
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
{ label: '1 天', value: 24 },
{ label: '7 天', value: 168 },
{ label: '30 天', value: 720 },
]
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
function openParModal(objectName: string) {
Object.assign(parForm, { objectName, accessType: 'ObjectRead', expiresHours: 24 })
parUrl.value = ''
showPar.value = true
}
async function doCreatePar() {
if (!props.cfgId || !parForm.expiresHours) return
parBusy.value = true
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: parForm.objectName,
accessType: parForm.accessType,
expiresHours: parForm.expiresHours,
})
parUrl.value = par.fullUrl ?? ''
parPanel.value?.refresh()
} catch (e) {
message.error(e instanceof Error ? e.message : '签发失败')
} finally {
parBusy.value = false
}
}
async function copyParUrl() {
await navigator.clipboard.writeText(parUrl.value)
message.success('链接已复制')
}
// ---- 批量选择 ----
const checkedKeys = ref<string[]>([])
async function batchDelete() {
for (const name of [...checkedKeys.value]) await removeObject(name)
}
async function batchDownload() {
for (const name of [...checkedKeys.value]) {
await download(name)
await new Promise((r) => setTimeout(r, 300))
}
}
// ---- 对象查看器:点击对象名打开,预览/编辑合一 ----
const viewerObject = ref('')
const showViewer = ref(false)
function openViewer(name: string) {
viewerObject.value = name
showViewer.value = true
}
interface Row {
key: string
isFolder: boolean
obj?: ObjectSummary
}
const rows = computed<Row[]>(() => {
const data = result.data.value
if (!data) return []
const folders = (data.prefixes ?? []).map((p) => ({ key: p, isFolder: true }))
// 文件夹占位对象(名字等于当前前缀自身)不展示
const files = (data.objects ?? [])
.filter((o) => o.name !== prefix.value)
.map((o) => ({ key: o.name, isFolder: false, obj: o }))
const all = [...folders, ...files]
const q = filterInput.value.trim().toLowerCase()
if (!q) return all
return all.filter((r) => r.key.slice(prefix.value.length).toLowerCase().includes(q))
})
const columns = computed<DataTableColumns<Row>>(() => [
{ type: 'selection', disabled: (row) => row.isFolder },
{
title: '名称',
key: 'name',
minWidth: 260,
// 超长对象名截断,悬停 tooltip 看全名
ellipsis: { tooltip: true },
render: (row) =>
row.isFolder
? h(
'span',
{
class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] font-medium hover:text-accent',
onClick: () => enterFolder(row.key),
},
[h(FileIcon, { folder: true }), row.key.slice(prefix.value.length)],
)
: h(
'span',
{
class: 'mono inline-flex cursor-pointer items-center gap-1.5 text-[13px] hover:text-accent',
onClick: () => openViewer(row.key),
},
[h(FileIcon, { name: row.key }), row.key.slice(prefix.value.length)],
),
},
{
title: '大小',
key: 'size',
width: 110,
render: (row) => h('span', { class: 'mono text-[12.5px]' }, row.isFolder ? '—' : fmtBytes(row.obj!.size)),
},
{
title: '存储层',
key: 'tier',
width: 110,
render: (row) => {
if (row.isFolder) return h('span', { class: 'text-ink-3' }, '—')
const o = row.obj!
if (o.storageTier === 'Archive')
return h(
'span',
{ class: 'rounded-full bg-warn/14 px-2 py-0.5 text-[11.5px] font-medium text-warn' },
o.archivalState === 'Restored'
? '已取回'
: o.archivalState === 'Restoring'
? '取回中'
: 'Archive',
)
return h('span', { class: 'text-[13px]' }, o.storageTier || 'Standard')
},
},
{
title: '修改时间',
key: 'time',
width: 150,
render: (row) =>
h(
'span',
{ class: 'text-[13px] text-ink-2' },
row.isFolder ? '—' : fmtTime(row.obj!.timeModified),
),
},
{
title: '操作',
key: 'actions',
width: 120,
render: (row) => {
if (row.isFolder) return null
const o = row.obj!
const restoring = o.storageTier === 'Archive' && o.archivalState === 'Restoring'
const archived =
o.storageTier === 'Archive' && o.archivalState !== 'Restored' && !restoring
return h('div', { class: 'flex items-center gap-1' }, [
restoring
? h(
NButton,
{ size: 'tiny', quaternary: true, disabled: true },
{ default: () => '取回中' },
)
: archived
? h(
ConfirmPop,
{
title: '取回 Archive 对象?',
content: '约 1 小时后可下载 (24 小时有效)。',
kind: 'plain',
confirmText: '取回',
onConfirm: () => restore(o.name),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true }, { default: () => '恢复' }),
},
)
: h(
NButton,
{ size: 'tiny', quaternary: true, onClick: () => download(o.name) },
{ default: () => '下载' },
),
h(
ConfirmPop,
{
title: '删除该对象?',
content: '未开版本控制时不可恢复。',
confirmText: '删除',
onConfirm: () => removeObject(o.name),
},
{
trigger: () =>
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error' },
{ default: () => '删除' },
),
},
),
])
},
},
])
</script>
<template>
<div class="flex flex-col gap-4">
<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="flex flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
<span class="cursor-pointer hover:text-ink" @click="emit('back')">存储桶</span>
<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('')"
>
{{ bucket }}
</span>
<template v-if="foldedOptions.length">
<span>/</span>
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink"></span>
</NDropdown>
</template>
<template v-for="c in visibleCrumbs" :key="c.prefix">
<span>/</span>
<span
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
:title="c.label"
@click="enterFolder(c.prefix)"
>
{{ c.label }}
</span>
</template>
</div>
<div class="mt-0.5 text-xs text-ink-3">
前缀模式展示,文件夹为虚拟层级 · 点击对象名预览 / 查看详情 · 上传经 PAR 直传不经面板中转
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<NInput
v-model:value="filterInput"
size="small"
placeholder="搜索本层对象…"
clearable
class="!w-52"
/>
<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>
<div
v-if="checkedKeys.length"
class="flex items-center gap-2.5 border-b border-line-soft bg-wash/60 px-4.5 py-2 text-[12.5px]"
>
<span class="font-medium">已选 {{ checkedKeys.length }} </span>
<NButton size="tiny" @click="batchDownload">逐个下载</NButton>
<ConfirmPop
:title="`删除选中的 ${checkedKeys.length} 个对象?`"
content="未开版本控制时不可恢复。"
confirm-text="删除"
:on-confirm="batchDelete"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary>删除</NButton>
</template>
</ConfirmPop>
<span class="ml-auto cursor-pointer text-ink-3 hover:text-ink" @click="checkedKeys = []">
清除选择
</span>
</div>
<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"
:data="rows"
:loading="result.loading.value"
:scroll-x="880"
: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"
>
<span>每页 {{ PAGE_SIZE }} 条 · Archive 层需恢复后才可下载</span>
<span class="flex items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
下一页
</NButton>
</span>
</div>
</div>
<ParPanel ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
<ObjectViewerModal
v-model:show="showViewer"
:cfg-id="cfgId"
:region="region"
:bucket="bucket"
:object="viewerObject"
:versioning-on="versioningOn"
:visibility="visibility"
:namespace="namespace"
@download="download"
@share="openParModal"
@changed="() => void result.run({ silent: true })"
/>
<FormModal
:show="showNewFolder"
title="新建文件夹"
:width="380"
:submitting="creatingFolder"
:submit-disabled="!folderName.trim()"
submit-text="创建"
@update:show="showNewFolder = $event"
@submit="doCreateFolder"
>
<FormField label="文件夹名" hint=" 0 字节对象实现虚拟层级">
<NInput v-model:value="folderName" placeholder="backup" class="mono" />
</FormField>
</FormModal>
<FormModal
:show="showPar"
title="分享对象"
:width="460"
:submitting="parBusy"
:submit-disabled="!!parUrl || !parForm.expiresHours"
submit-text="签发"
@update:show="showPar = $event"
@submit="doCreatePar"
>
<FormField label="对象">
<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>
</NRadioGroup>
</FormField>
<FormField label="有效期" hint="OCI 上限 30 (720 小时)">
<div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber
v-model:value="parForm.expiresHours"
:min="1"
:max="720"
:precision="0"
size="small"
class="!w-24"
/>
<span class="text-[12.5px] text-ink-2">小时</span>
<NButton
v-for="q in QUICK_HOURS"
:key="q.value"
size="tiny"
quaternary
:type="parForm.expiresHours === q.value ? 'primary' : 'default'"
@click="parForm.expiresHours = q.value"
>
{{ q.label }}
</NButton>
</div>
</FormField>
<div
v-if="parUrl"
class="mt-1 flex items-center gap-2 rounded-lg border border-line-soft bg-wash px-3 py-2"
>
<span class="mono min-w-0 flex-1 truncate text-xs">{{ parUrl }}</span>
<NButton size="tiny" @click="copyParUrl">复制</NButton>
</div>
<div v-if="parUrl" class="mt-1 text-xs text-warn">
链接仅本次展示,关闭后无法再次获取,请立即保存
</div>
</FormModal>
</div>
</template>
@@ -0,0 +1,517 @@
<script setup lang="ts">
import { NButton, NInput, NModal, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
deleteObject,
getObjectContent,
getObjectDetail,
putObjectContent,
renameObject,
} from '@/api/objectstorage'
import { ApiError } from '@/api/request'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { extOf, previewKindOf } from '@/components/objectstorage/codeHighlight'
import { canFormat, formatSource } from '@/components/objectstorage/format'
import FileIcon from '@/components/objectstorage/FileIcon.vue'
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
import TextEditPane from '@/components/objectstorage/viewer/TextEditPane.vue'
import TextPreviewPane from '@/components/objectstorage/viewer/TextPreviewPane.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
/** 对象查看器:预览与编辑合一,左内容区 + 右信息栏。
* 内容经面板中转直读直写(不签发 PAR);保存以 If-Match 做并发保护,
* 412 冲突与无版本控制桶走二段确认。 */
const props = defineProps<{
show: boolean
cfgId: number | null
region?: string
bucket: string
object: string
/** 桶版本控制状态;未知按未开启保守处理 */
versioningOn?: boolean
/** 桶可见性(NoPublicAccess / ObjectRead);公共读时「复制 URI」变「复制 URL」 */
visibility?: string
/** 桶 namespace,拼公网对象 URL 用 */
namespace?: string
}>()
const emit = defineEmits<{
'update:show': [boolean]
download: [string]
share: [string]
/** 保存/重命名/删除后,列表需要刷新 */
changed: []
}>()
const message = useToast()
/** 文本预览/编辑体积上限;更大的文本请下载查看 */
const TEXT_MAX = 1024 * 1024
/** 图片与 office/pdf 的中转上限(与后端 20MB 限制一致) */
const BINARY_MAX = 20 * 1024 * 1024
/** pre 渲染字符上限,超出截断展示 */
const TEXT_SHOW_MAX = 256 * 1024
const detail = useAsync(async () => {
if (!props.cfgId || !props.object) return null
return getObjectDetail(props.cfgId, props.bucket, props.object, props.region)
}, false)
const ext = computed(() => extOf(props.object))
const kind = computed(() => previewKindOf(props.object))
const archived = computed(() => {
const d = detail.data.value
return d?.storageTier === 'Archive' && d.archivalState !== 'Restored'
})
const size = computed(() => detail.data.value?.size ?? 0)
const tooBig = computed(() =>
kind.value === 'text' ? size.value > TEXT_MAX : size.value > BINARY_MAX,
)
const canEdit = computed(() => kind.value === 'text' && !archived.value && !tooBig.value)
const officeKind = computed(() => {
const k = kind.value
return k === 'docx' || k === 'xlsx' || k === 'pdf' || k === 'pptx' ? k : null
})
const blockedReason = computed(() => {
if (kind.value === 'none') return '该格式暂不支持预览,请下载查看'
if (archived.value) return 'Archive 对象需先恢复才能预览'
if (tooBig.value)
return kind.value === 'text' ? '文本超过 1 MB,请下载查看' : '文件超过 20 MB,请下载查看'
return ''
})
// ---- 内容状态 ----
const mode = ref<'preview' | 'edit'>('preview')
const loading = ref(false)
const loadError = ref('')
/** 文本完整内容(编辑基线,不做美化改写) */
const fullText = ref('')
/** 展示文本(json 美化、超长截断) */
const previewText = ref('')
const truncated = ref(false)
const imageUrl = ref('')
const officeData = ref<ArrayBuffer | null>(null)
const officeError = ref('')
const contentEtag = ref('')
const contentType = ref('')
watch(
() => [props.show, props.object],
([show]) => {
if (!show) {
revokeImage()
return
}
reset()
void detail.run().then(() => {
if (!blockedReason.value) void loadContent()
})
},
)
onBeforeUnmount(revokeImage)
function reset() {
mode.value = 'preview'
// 立即清掉上一对象的元数据,右栏转入加载态,避免残留展示
detail.data.value = null
loading.value = false
loadError.value = ''
fullText.value = ''
previewText.value = ''
truncated.value = false
revokeImage()
officeData.value = null
officeError.value = ''
contentEtag.value = ''
contentType.value = ''
renaming.value = false
resetEdit()
}
function revokeImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function loadContent() {
if (!props.cfgId || loading.value) return
loading.value = true
loadError.value = ''
try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
contentEtag.value = c.etag
contentType.value = c.contentType
await applyContent(c.blob)
} catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally {
loading.value = false
}
}
async function applyContent(blob: Blob) {
if (kind.value === 'image') {
imageUrl.value = URL.createObjectURL(blob)
return
}
if (kind.value === 'text') {
setText(await blob.text())
return
}
officeData.value = await blob.arrayBuffer()
}
function setText(raw: string) {
fullText.value = raw
const pretty = ext.value === 'json' ? prettyJson(raw) : raw
truncated.value = pretty.length > TEXT_SHOW_MAX
previewText.value = truncated.value ? pretty.slice(0, TEXT_SHOW_MAX) : pretty
}
/** JSON 美化失败时原样展示 */
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2)
} catch {
return s
}
}
// ---- 编辑 ----
const editText = ref('')
const saving = ref(false)
/** 非空时保存进入二段确认态,内容为需用户确认的原因 */
const pendingConfirm = ref('')
/** 412 冲突确认后置真:下次保存不带 If-Match,无条件覆盖 */
const overwrite = ref(false)
const editByteSize = computed(() => new Blob([editText.value]).size)
function resetEdit() {
editText.value = ''
saving.value = false
pendingConfirm.value = ''
overwrite.value = false
}
function startEdit() {
editText.value = fullText.value
pendingConfirm.value = ''
overwrite.value = false
mode.value = 'edit'
}
function cancelEdit() {
mode.value = 'preview'
resetEdit()
}
const canBeautify = computed(() => canFormat(ext.value))
/** 一键美化:json 原生,其余 prettier standalone(动态加载);语法错误只提示不改动 */
async function beautify() {
try {
const out = await formatSource(editText.value, ext.value)
if (out === null) return
if (out === editText.value) {
message.success('已是规范格式')
return
}
editText.value = out
message.success('已格式化')
} catch {
message.error('格式化失败:内容存在语法错误')
}
}
async function save() {
if (!props.cfgId || saving.value) return
if (!pendingConfirm.value && !props.versioningOn) {
pendingConfirm.value = '该桶未开启版本控制,保存将直接覆盖原内容,不可恢复。'
return
}
saving.value = true
try {
const { etag } = await putObjectContent(
props.cfgId,
props.bucket,
props.object,
editText.value,
contentType.value || 'text/plain',
overwrite.value ? undefined : contentEtag.value || undefined,
props.region,
)
applySaved(etag)
} catch (e) {
onSaveError(e)
} finally {
saving.value = false
}
}
function applySaved(etag: string) {
contentEtag.value = etag
setText(editText.value)
message.success('已保存')
mode.value = 'preview'
resetEdit()
emit('changed')
void detail.run({ silent: true })
}
function onSaveError(e: unknown) {
if (e instanceof ApiError && e.status === 412) {
overwrite.value = true
pendingConfirm.value = '对象在编辑期间已被修改(ETag 变化),继续保存将覆盖他人的更改。'
return
}
message.error(e instanceof Error ? e.message : '保存失败')
}
// ---- 重命名 / 删除 / 复制 ----
const renaming = ref(false)
const newName = ref('')
const renameBusy = ref(false)
function startRename() {
newName.value = props.object
renaming.value = true
}
async function doRename() {
if (!props.cfgId || !newName.value.trim() || newName.value === props.object) return
renameBusy.value = true
try {
await renameObject(props.cfgId, props.bucket, props.object, newName.value.trim(), props.region)
message.success('已重命名')
emit('update:show', false)
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '重命名失败')
} finally {
renameBusy.value = false
}
}
async function doDelete() {
if (!props.cfgId) return
try {
await deleteObject(props.cfgId, props.bucket, props.object, props.region)
message.success('对象已删除')
emit('update:show', false)
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
/** Archive 层(任意恢复态):PAR 对其无效,分享一律禁用 */
const isArchiveTier = computed(() => detail.data.value?.storageTier === 'Archive')
/** 公共读桶:对象有公网直链,「复制 URI」升级为「复制 URL」 */
const isPublicBucket = computed(() => props.visibility === 'ObjectRead')
const scope = useScopeStore()
/** 公共读桶对象的公网直链;对象名整体转义(/ → %2F,OCI 控制台同款) */
function publicUrl(): string {
const region = props.region || scope.region
return `https://objectstorage.${region}.oraclecloud.com/n/${props.namespace}/b/${props.bucket}/o/${encodeURIComponent(props.object)}`
}
async function copyUri() {
const pub = isPublicBucket.value
await navigator.clipboard.writeText(pub ? publicUrl() : `oci://${props.bucket}/${props.object}`)
message.success(pub ? 'URL 已复制' : 'URI 已复制')
}
const kvRows = computed(() => {
const d = detail.data.value
if (!d) return []
return [
{ k: '大小', v: `${fmtBytes(d.size)}(${d.size.toLocaleString()} B)` },
{ k: 'Content-Type', v: d.contentType || '—', mono: true },
{ k: 'ETag', v: d.etag || '—', mono: true },
{ k: 'MD5', v: d.contentMd5 || '—', mono: true },
{ k: '存储层', v: d.storageTier || 'Standard' },
{ k: '版本控制', v: props.versioningOn ? '已开启' : '未开启' },
{ k: '修改时间', v: fmtTime(d.timeModified) },
]
})
</script>
<template>
<NModal
:show="show"
preset="card"
:mask-closable="mode !== 'edit'"
:style="{ width: '1080px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<template #header>
<span class="mono flex min-w-0 items-center gap-2 text-[13.5px]">
<FileIcon :name="object" :size="18" />
<span class="truncate" :title="object">{{ object }}</span>
<span
v-if="mode === 'edit'"
class="flex-none rounded bg-wash px-1.5 py-px text-[10.5px] font-medium text-ink-3"
>
编辑
</span>
</span>
</template>
<div class="flex flex-col gap-4 md:flex-row">
<!-- :内容区 -->
<div
class="flex h-[65vh] min-w-0 flex-1 flex-col rounded-lg border border-line-soft bg-wash/50 p-3"
>
<div
v-if="detail.loading.value || loading"
class="flex flex-1 items-center justify-center"
>
<NSpin size="small" />
</div>
<div
v-else-if="blockedReason"
class="flex flex-1 items-center justify-center text-xs text-ink-3"
>
{{ blockedReason }}
</div>
<div v-else-if="loadError" class="flex flex-1 flex-col items-center justify-center gap-2">
<div class="text-xs text-err">{{ loadError }}</div>
<NButton size="small" @click="loadContent">重试</NButton>
</div>
<template v-else-if="mode === 'edit'">
<div
class="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-white transition-colors focus-within:border-accent"
>
<TextEditPane v-model="editText" :object-name="object" :disabled="saving" />
</div>
<div class="mt-1.5 flex-none text-xs text-ink-3">
UTF-8 文本 · {{ fmtBytes(editByteSize) }} · 保存经面板中转写回,If-Match 防并发覆盖
</div>
</template>
<img
v-else-if="imageUrl"
:src="imageUrl"
:alt="object"
class="mx-auto max-h-full min-h-0 rounded object-contain"
@error="loadError = '图片加载失败,请下载查看'; revokeImage()"
/>
<TextPreviewPane
v-else-if="kind === 'text' && (previewText || truncated || !loading)"
:text="previewText"
:ext="ext"
:truncated="truncated"
/>
<template v-else-if="officeData && officeKind">
<div
v-if="officeError"
class="flex flex-1 items-center justify-center text-xs text-err"
>
{{ officeError }}
</div>
<div v-else class="min-h-0 flex-1 overflow-auto rounded bg-white">
<OfficePreview :kind="officeKind" :data="officeData" @error="officeError = $event" />
</div>
<div v-if="officeKind === 'pptx' && !officeError" class="mt-1.5 flex-none text-xs text-ink-3">
pptx 预览为实验能力,复杂版式可能失真
</div>
</template>
</div>
<!-- :信息与操作 -->
<div class="flex w-full flex-none flex-col gap-3 md:w-72">
<div v-if="detail.loading.value" class="flex justify-center py-10">
<NSpin size="small" />
</div>
<template v-else>
<div class="flex flex-col gap-0.5">
<div
v-for="row in kvRows"
:key="row.k"
class="flex justify-between gap-3 border-b border-line-soft py-1.5 text-[12px]"
>
<span class="flex-none text-ink-3">{{ row.k }}</span>
<span class="min-w-0 text-right break-all" :class="row.mono ? 'mono' : ''">
{{ row.v }}
</span>
</div>
</div>
<template v-if="mode === 'edit'">
<div
v-if="pendingConfirm"
class="rounded-lg bg-warn/12 px-3 py-2 text-xs leading-relaxed text-warn"
>
{{ pendingConfirm }}
</div>
<NButton v-if="canBeautify" size="small" :disabled="saving" @click="beautify">
美化格式
</NButton>
<div class="flex gap-2">
<NButton
size="small"
type="primary"
class="flex-1"
:loading="saving"
@click="save"
>
{{ pendingConfirm ? '确认覆盖并保存' : '保存' }}
</NButton>
<NButton size="small" :disabled="saving" @click="cancelEdit">取消</NButton>
</div>
</template>
<div v-else-if="renaming" class="flex flex-col gap-2">
<NInput v-model:value="newName" size="small" class="mono" />
<div class="flex gap-2">
<NButton size="small" type="primary" :loading="renameBusy" @click="doRename">
确定
</NButton>
<NButton size="small" :disabled="renameBusy" @click="renaming = false">取消</NButton>
</div>
</div>
<template v-else>
<div :title="archived ? 'Archive 对象需先恢复才能下载' : ''">
<NButton
size="small"
type="primary"
block
:disabled="archived"
@click="emit('download', object)"
>
下载
</NButton>
</div>
<div class="grid grid-cols-2 gap-1.5">
<NButton v-if="canEdit" size="small" @click="startEdit">编辑</NButton>
<div :title="isArchiveTier ? 'Archive 层对象不支持预签名分享' : ''">
<NButton size="small" block :disabled="isArchiveTier" @click="emit('share', object)">
分享
</NButton>
</div>
<NButton size="small" @click="copyUri">
{{ isPublicBucket ? '复制 URL' : '复制 URI' }}
</NButton>
<NButton size="small" @click="startRename">重命名</NButton>
</div>
<ConfirmPop
title="删除该对象?"
content="未开版本控制时不可恢复。"
confirm-text="删除"
:on-confirm="doDelete"
>
<template #trigger>
<NButton size="small" type="error" ghost block>删除</NButton>
</template>
</ConfirmPop>
</template>
</template>
</div>
</div>
</NModal>
</template>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
import { NButton, NDataTable, type DataTableColumns } from 'naive-ui'
import { computed, h, ref, watch } from 'vue'
import { deleteAllPars, deletePar, listPars } from '@/api/objectstorage'
import ConfirmPop from '@/components/ConfirmPop.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { Par, ParPage } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number | null; region?: string; bucket: string }>()
const message = useToast()
/** 服务端游标分页:几千条 PAR 的桶全量拉取会把浏览器主线程打满数秒 */
const PAGE_SIZE = 100
/** 上页游标栈:后端只回传 next 游标,上一页靠历史栈回放 */
const cursorStack = ref<string[]>([])
const cursor = ref('')
const pars = useAsync<ParPage>(async () => {
if (!props.cfgId) return { items: [], nextPage: '' }
try {
return await listPars(props.cfgId, props.bucket, {
region: props.region,
page: cursor.value || undefined,
limit: PAGE_SIZE,
})
} catch {
return { items: [], nextPage: '' }
}
}, false)
const items = computed(() => pars.data.value?.items ?? [])
watch(
[() => props.cfgId, () => props.bucket],
() => {
cursorStack.value = []
cursor.value = ''
void pars.run()
},
{ immediate: true },
)
function goNext() {
const next = pars.data.value?.nextPage
if (!next) return
cursorStack.value.push(cursor.value)
cursor.value = next
void pars.run()
}
function goPrev() {
cursor.value = cursorStack.value.pop() ?? ''
void pars.run()
}
/** 回到首页重拉;签发新 PAR 后的刷新走这里(新条目排序位置不定,回首页语义最直观) */
function refreshFirstPage(opts?: { silent?: boolean }) {
cursorStack.value = []
cursor.value = ''
void pars.run(opts)
}
defineExpose({ refresh: () => refreshFirstPage({ silent: true }) })
async function remove(row: Par) {
if (!props.cfgId) return
try {
await deletePar(props.cfgId, props.bucket, row.id, props.region)
message.success('分享链接已删除并立即失效')
await pars.run({ silent: true })
// 当前页删空且非首页时自动退回上一页
if (!items.value.length && cursorStack.value.length) goPrev()
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
async function removeAll() {
if (!props.cfgId) return
try {
const { deleted } = await deleteAllPars(props.cfgId, props.bucket, props.region)
message.success(`已删除 ${deleted} 条分享链接`)
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
} finally {
refreshFirstPage()
}
}
const ACCESS_LABEL: Record<string, string> = {
ObjectRead: '只读',
ObjectWrite: '只写',
ObjectReadWrite: '读写',
AnyObjectRead: '桶级只读',
AnyObjectWrite: '桶级只写',
AnyObjectReadWrite: '桶级读写',
}
const columns = computed<DataTableColumns<Par>>(() => [
{
title: '目标',
key: 'objectName',
minWidth: 220,
ellipsis: { tooltip: true },
render: (r) => h('span', { class: 'mono text-[12.5px]' }, r.objectName || '(桶级)'),
},
{
title: '权限',
key: 'accessType',
width: 120,
render: (r) =>
h(
'span',
{
class:
'rounded-full bg-wash px-2 py-0.5 text-[11.5px] font-medium whitespace-nowrap text-ink-2',
},
ACCESS_LABEL[r.accessType] ?? r.accessType,
),
},
{
title: '过期时间',
key: 'timeExpires',
width: 160,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeExpires)),
},
{
title: '创建',
key: 'timeCreated',
width: 150,
render: (r) => h('span', { class: 'text-[13px] text-ink-2' }, fmtTime(r.timeCreated)),
},
{
title: '操作',
key: 'actions',
width: 90,
render: (r) =>
h(
ConfirmPop,
{
title: '删除该临时链接?',
content: '删除后链接立即失效。',
confirmText: '删除',
onConfirm: () => remove(r),
},
{
trigger: () =>
h(
NButton,
{ size: 'tiny', quaternary: true, type: 'error' },
{ default: () => '删除' },
),
},
),
},
])
</script>
<template>
<div class="panel">
<PanelHeader
title="分享链接(PAR)"
desc="完整 URL 仅签发时展示一次(OCI 不支持事后取回);删除后链接立即失效"
>
<template #title-extra>
<span class="text-[13px] font-normal text-ink-3">
· 本页 {{ pars.data.value ? items.length : '…' }}{{ pars.data.value?.nextPage ? '+' : '' }}
</span>
</template>
<ConfirmPop
title="删除全部分享链接?"
content="将删除桶内全部分享链接(含未展示分页),删除后全部立即失效。"
confirm-text="全部删除"
:on-confirm="removeAll"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary :disabled="!items.length">
删除全部
</NButton>
</template>
</ConfirmPop>
</PanelHeader>
<NDataTable
:columns="columns"
:data="items"
:loading="pars.loading.value"
:pagination="false"
:scroll-x="740"
:row-key="(r: Par) => r.id"
/>
<div
v-if="cursorStack.length || pars.data.value?.nextPage"
class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
>
<span>每页 {{ PAGE_SIZE }} 条</span>
<span class="flex items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="goPrev">上一页</NButton>
<NButton size="tiny" :disabled="!pars.data.value?.nextPage" @click="goNext">下一页</NButton>
</span>
</div>
</div>
</template>
@@ -0,0 +1,94 @@
import type { LanguageFn } from 'highlight.js'
const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'])
const TEXT_EXT = new Set([
'txt', 'md', 'log', 'json', 'yaml', 'yml', 'ini', 'toml', 'conf', 'cfg', 'csv',
'xml', 'html', 'htm', 'css', 'js', 'ts', 'sh', 'py', 'go', 'env', 'properties', 'sql',
])
/** office/pdf 预览形态(vue-office 渲染);doc/xls/ppt 老格式不支持 */
const OFFICE_KIND: Record<string, 'docx' | 'xlsx' | 'pdf' | 'pptx'> = {
docx: 'docx',
xlsx: 'xlsx',
pdf: 'pdf',
pptx: 'pptx',
}
export type PreviewKind = 'image' | 'text' | 'docx' | 'xlsx' | 'pdf' | 'pptx' | 'none'
export function extOf(name: string): string {
return name.split('.').pop()?.toLowerCase() ?? ''
}
/** 对象是否支持预览及预览形态 */
export function previewKindOf(name: string): PreviewKind {
const ext = extOf(name)
// svg 按文本处理:预览「渲染 ↔ 代码」双态由文本生成,且可在线编辑
if (ext === 'svg') return 'text'
if (IMAGE_EXT.has(ext)) return 'image'
if (TEXT_EXT.has(ext)) return 'text'
return OFFICE_KIND[ext] ?? 'none'
}
/** 扩展名 → highlight.js 语言;不在表内的按纯文本展示 */
const EXT_LANG: Record<string, string> = {
py: 'python',
json: 'json',
yaml: 'yaml',
yml: 'yaml',
ini: 'ini',
toml: 'ini',
conf: 'ini',
cfg: 'ini',
env: 'ini',
properties: 'ini',
xml: 'xml',
html: 'xml',
htm: 'xml',
sh: 'bash',
js: 'javascript',
ts: 'typescript',
go: 'go',
sql: 'sql',
md: 'markdown',
css: 'css',
svg: 'xml',
}
/** 语言按需动态加载,各自成 chunk,不预览不下载 */
const LANG_LOADERS: Record<string, () => Promise<{ default: LanguageFn }>> = {
python: () => import('highlight.js/lib/languages/python'),
json: () => import('highlight.js/lib/languages/json'),
yaml: () => import('highlight.js/lib/languages/yaml'),
ini: () => import('highlight.js/lib/languages/ini'),
xml: () => import('highlight.js/lib/languages/xml'),
bash: () => import('highlight.js/lib/languages/bash'),
javascript: () => import('highlight.js/lib/languages/javascript'),
typescript: () => import('highlight.js/lib/languages/typescript'),
go: () => import('highlight.js/lib/languages/go'),
sql: () => import('highlight.js/lib/languages/sql'),
markdown: () => import('highlight.js/lib/languages/markdown'),
css: () => import('highlight.js/lib/languages/css'),
}
/**
* 高亮一段代码;lang 可传语言名(md 围栏标签,如 python/bash)或扩展名(如 py/sh)。
* 返回 hljs 生成的 HTML(输入已转义,可安全 v-html);不支持或失败返回 null。
*/
export async function highlightCode(code: string, lang: string): Promise<string | null> {
const name = LANG_LOADERS[lang] ? lang : EXT_LANG[lang]
const load = name ? LANG_LOADERS[name] : undefined
if (!name || !load) return null
try {
const { default: hljs } = await import('highlight.js/lib/core')
if (!hljs.getLanguage(name)) hljs.registerLanguage(name, (await load()).default)
return hljs.highlight(code, { language: name }).value
} catch {
return null
}
}
/** 按扩展名高亮整份源码,语义同 highlightCode(ext 分支)。 */
export async function highlightSource(text: string, ext: string): Promise<string | null> {
return highlightCode(text, ext)
}
+60
View File
@@ -0,0 +1,60 @@
// 由 scripts/gen-file-icons.mjs 从 @iconify-json/catppuccin 提取,勿手改;
// 来源:Catppuccin VSCode Icons(MIT),16x16 viewBox。
export const FILE_ICON_BODIES: Record<string, string> = {
"folder":
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M4.5 4.5H12c.83 0 1.5.67 1.5 1.5v6c0 .83-.67 1.5-1.5 1.5H2A1.5 1.5 0 0 1 .5 12V3.5a1 1 0 0 1 1-1h5a1 1 0 0 1 1 1v1"/>',
"file":
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M13.5 6.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01m-.01 0l5 5h-4a1 1 0 0 1-1-1z"/>',
"image":
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#eed49f" d="M11.5 6A1.5 1.5 0 0 1 10 7.5A1.5 1.5 0 0 1 8.5 6A1.5 1.5 0 0 1 10 4.5A1.5 1.5 0 0 1 11.5 6"/><path stroke="#a6da95" d="M7.5 13.5L11 10c.5-.5 1.5-.5 2 0l1.5 1.5"/><path stroke="#a6da95" d="m1.5 9.5l2-2C4 7 5 7 5.5 7.5l4 4"/><path stroke="#7dc4e4" d="M3 2.5h10c.83 0 1.5.67 1.5 1.5v8c0 .83-.67 1.5-1.5 1.5H3A1.5 1.5 0 0 1 1.5 12V4c0-.83.67-1.5 1.5-1.5"/></g>',
"json":
'<path fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round" d="M4.5 2.5H4c-.75 0-1.5.75-1.5 1.5v2c0 1.1-1 2-1.83 2c.83 0 1.83.9 1.83 2v2c0 .75.75 1.5 1.5 1.5h.5m7-11h.5c.75 0 1.5.75 1.5 1.5v2c0 1.1 1 2 1.83 2c-.83 0-1.83.9-1.83 2v2c0 .74-.75 1.5-1.5 1.5h-.5m-6.5-3a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1m3 0a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1m3 0a.5.5 0 1 0 0-1a.5.5 0 0 0 0 1"/>',
"yaml":
'<path fill="none" stroke="#ed8796" stroke-linecap="round" stroke-linejoin="round" d="M2.5 1.5h3l3 4l3-4h3l-9 13h-3L7 8z"/>',
"toml":
'<path fill="none" stroke="#ee99a0" stroke-linecap="round" stroke-linejoin="round" d="M3.5 1.5h-2v13h2m9-13h2v13h-2m-8-11h7v3h-2v6h-3v-6h-2z"/>',
"properties":
'<path fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round" d="M8 1.5c-.87 0-1.17 1.32-2.03 1.63c-.86.3-2.17-.68-2.84 0c-.68.67.3 1.98 0 2.84S1.5 7.13 1.5 8s1.32 1.17 1.63 2.03c.3.86-.68 2.17 0 2.85c.67.67 1.98-.3 2.84 0c.85.3 1.16 1.62 2.03 1.62s1.17-1.32 2.03-1.63c.86-.3 2.17.68 2.85 0c.67-.67-.3-1.98 0-2.84c.3-.85 1.62-1.16 1.62-2.03s-1.32-1.17-1.63-2.03c-.3-.86.68-2.17 0-2.84c-.67-.68-1.98.3-2.84 0S8.87 1.5 8 1.5m0 9a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5"/>',
"python":
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#8aadf4" d="M8.5 5.5h-3m6 0V3c0-.8-.7-1.5-1.5-1.5H7c-.8 0-1.5.7-1.5 1.5v2.5H3c-.8 0-1.5.7-1.5 1.5v2c0 .8.7 1.5 1.48 1.5"/><path stroke="#eed49f" d="M10.5 10.5h-3m-3 0V13c0 .8.7 1.5 1.5 1.5h3c.8 0 1.5-.7 1.5-1.5v-2.5H13c.8 0 1.5-.7 1.5-1.5V7c0-.8-.7-1.5-1.48-1.5H11.5c0 1.5 0 2-1 2h-2"/><path stroke="#8aadf4" d="M2.98 10.5H4.5c0-1.5 0-2 1-2h2m0-5"/></g>',
"javascript":
'<g fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 11a1.5 1.5 0 0 0 3 0V7.5m5 1.25c0-.69-.537-1.25-1.2-1.25h-.6c-.663 0-1.2.56-1.2 1.25S10.037 10 10.7 10h.6c.663 0 1.2.56 1.2 1.25s-.537 1.25-1.2 1.25h-.6c-.663 0-1.2-.56-1.2-1.25"/><path d="M4 1.5h8c1.385 0 2.5 1.115 2.5 2.5v8c0 1.385-1.115 2.5-2.5 2.5H4A2.495 2.495 0 0 1 1.5 12V4c0-1.385 1.115-2.5 2.5-2.5"/></g>',
"typescript":
'<g fill="none" stroke="#8aadf4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 1.5h8A2.5 2.5 0 0 1 14.5 4v8a2.5 2.5 0 0 1-2.5 2.5H4A2.5 2.5 0 0 1 1.5 12V4A2.5 2.5 0 0 1 4 1.5"/><path d="M12.5 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S10.04 10 10.7 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25m-3-3.75v5M5 7.5h3"/></g>',
"markdown":
'<path fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round" d="m9.25 8.25l2.25 2.25l2.25-2.25M3.5 11V5.5l2.04 3l1.96-3V11m4-.5V5M1.65 2.5h12.7c.59 0 1.15.49 1.15 1v9c0 .51-.56 1-1.15 1H1.65c-.59 0-1.15-.49-1.15-1V3.58c0-.5.56-1.08 1.15-1.08"/>',
"database":
'<path fill="none" stroke="#eed49f" stroke-linecap="round" stroke-linejoin="round" d="M8 6.5c3.59 0 6.5-1.4 6.5-2.68S11.59 1.5 8 1.5S1.5 2.54 1.5 3.82S4.41 6.5 8 6.5M14.5 8c0 .83-1.24 1.79-3.25 2.2s-4.49.41-6.5 0S1.5 8.83 1.5 8m13 4.18c0 .83-1.24 1.6-3.25 2c-2.01.42-4.49.42-6.5 0c-2.01-.4-3.25-1.17-3.25-2m0-8.3v8.3m13-8.3v8.3"/>',
"bash":
'<g fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round"><path d="M2 15.5c-.7 0-1.5-.8-1.5-1.5V5c0-.7.8-1.5 1.5-1.5h9c.7 0 1.5.8 1.5 1.5v9c0 .7-.8 1.5-1.5 1.5z"/><path d="m1.2 3.8l3.04-2.5S5.17.5 5.7.5h8.4c.66 0 1.4.73 1.4 1.4v7.73a2.7 2.7 0 0 1-.7 1.75l-2.68 3.51"/><path d="M6 8.75c0-.69-.54-1.25-1.2-1.25h-.6c-.66 0-1.2.56-1.2 1.25S3.54 10 4.2 10h.6c.66 0 1.2.56 1.2 1.25s-.54 1.25-1.2 1.25h-.6c-.66 0-1.2-.56-1.2-1.25M4.5 6.5v1m0 5v1"/></g>',
"go":
'<path fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round" d="m15.48 8.06l-4.85.48m4.85-.48a4.98 4.98 0 0 1-4.54 5.42a5 5 0 1 1 2.95-8.66l-1.7 1.84a2.5 2.5 0 0 0-4.18 2.06c.05.57.3 1.1.69 1.51c.25.27 1 .83 1.78.82c.8-.02 1.58-.25 2.07-.81c0 0 .8-.96.68-1.88M2.5 8.5l-2 .01m1.5 2h1.5m-2-3.99l2-.02"/>',
"zip":
'<path fill="none" stroke="#cad3f5" stroke-linejoin="round" d="M5.5 10v1m1-2v1m-1-2v1m1-2v1m-1-2v1m1-2v1m-1-2v1m0-3v1m1 0v1m7 2.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01m-.01 0l5 5h-4a1 1 0 0 1-1-1z"/>',
"pdf":
'<path fill="none" stroke="#ed8796" stroke-linecap="round" stroke-linejoin="round" d="M2.8 14.34c1.81-1.25 3.02-3.16 3.91-5.5c.9-2.33 1.86-4.33 1.44-6.63c-.06-.36-.57-.73-.83-.7c-1.02.06-.95 1.21-.85 1.9c.24 1.71 1.56 3.7 2.84 5.56c1.27 1.87 2.32 2.16 3.78 2.26c.5.03 1.25-.14 1.37-.58c.77-2.8-9.02-.54-12.28 2.08c-.4.33-.86 1-.6 1.46c.2.36.87.4 1.23.15h0Z"/>',
"video":
'<g fill="none" stroke="#7dc4e4" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2.5h10c.83 0 1.5.67 1.5 1.5v9c0 .83-.67 1.5-1.5 1.5H3A1.5 1.5 0 0 1 1.5 13V4c0-.83.67-1.5 1.5-1.5m-1.5 3h13"/><path d="m3.5 5.5l2-3m1.5 3l2-3m1.5 3l2-3M6.5 8v4l4-2z"/></g>',
"audio":
'<g fill="none" stroke="#ee99a0" stroke-linecap="round" stroke-linejoin="round"><path d="M5.5 12.5a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2m9-2a2 2 0 0 1-2 2a2 2 0 0 1-2-2a2 2 0 0 1 2-2a2 2 0 0 1 2 2"/><path d="M5.5 12.5V5c0-.54.44-1.21 1.35-1.5l6.3-2c.9 0 1.35.88 1.35 1.5v7.58m-9-3.08l9-3"/></g>',
"html":
'<g fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="#f5a97f" d="M1.5 1.5h13L13 13l-5 2l-5-2z"/><path stroke="#cad3f5" d="M11 4.5H5l.25 3h5.5l-.25 3l-2.5 1l-2.5-1l-.08-1"/></g>',
"css":
'<g fill="none" stroke="#c6a0f6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 1.5h8A2.5 2.5 0 0 1 14.5 4v8a2.5 2.5 0 0 1-2.5 2.5H4A2.5 2.5 0 0 1 1.5 12V4A2.5 2.5 0 0 1 4 1.5"/><path stroke-width=".814" d="M10.24 11.53c0 .58.438 1.038.96 1.035l.453-.004c.522-.003.949-.451.949-1.033c0-.58-.427-1.065-.95-1.065h-.451c-.523 0-.95-.486-.95-1.066s.427-1.038.95-1.038h.452c.522 0 .951.458.951 1.039M6.8 11.529c0 .58.438 1.04.96 1.036l.465-.004c.523-.003.936-.451.936-1.031s-.409-1.066-.931-1.066h-.47c-.522 0-.949-.485-.949-1.066c0-.58.427-1.037.95-1.037h.451c.523 0 .964.457.964 1.037M3.407 11.53c0 .58.438 1.052.96 1.052h.452c.522 0 .95-.457.95-1.038m.01-2.131c0-.58-.437-1.038-.96-1.038h-.451c-.523 0-.96.468-.96 1.05v2.118"/></g>',
"text":
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 6.5v6a2 2 0 0 1-2 2h-7a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h4.01"/><path d="m8.5 1.5l5 5h-4a1 1 0 0 1-1-1zm-3 10h5m-5-3h5m-5-3h1"/></g>',
"log":
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 3.5h9v11h-9z"/><path d="M11.5 3.45V1.5h-9v11h1.95m3.05-5h3m-3 3h3"/></g>',
"csv":
'<path fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round" d="M1.5 3.5c0-.54.48-1 1.08-1H6.5l1.54 1h5.38c.6 0 1.08.44 1.08.98l-.09 9.04c0 .54-.48.98-1.08.98H2.58c-.6 0-1.08-.44-1.08-.98zm2 4v4m3-4v4m3-4v4m3-4v4m-9 0h9m-9-2h9m-9-2h9"/>',
"xml":
'<path fill="none" stroke="#f5a97f" stroke-linecap="round" stroke-linejoin="round" d="M4.5 4.5L1 8l3.5 3.5m7-7L15 8l-3.5 3.5M9.5 2l-3 12"/>',
"binary":
'<g fill="none" stroke="#cad3f5" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 1.5h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-9a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2"/><path d="M10.5 9.5h1v3.05M6 9.5h1c.28 0 .5.22.5.5v2a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5v-2c0-.28.22-.5.5-.5m4-6h1c.28 0 .5.22.5.5v2a.5.5 0 0 1-.5.5h-1a.5.5 0 0 1-.5-.5V4c0-.28.22-.5.5-.5m-4.5 0h1v3.05"/></g>',
"ms-word":
'<g fill="none" stroke="#8aadf4" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62"/><path d="m.5 5.5l1 5l1-5l1 5l.97-5m3.03 1h4m-4 3h4"/></g>',
"ms-excel":
'<g fill="none" stroke="#a6da95" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62M.5 5.5l4 5m0-5l-4 5"/><path d="M7.5 5.5h5v5h-5zm2 0v5m-2-3h5"/></g>',
"ms-powerpoint":
'<g fill="none" stroke="#f5a97f" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 3.13c0-.77.86-1.63 1.62-1.63h9.76c.76 0 1.62.86 1.62 1.63v9.75c0 .76-.86 1.62-1.62 1.62H4.13c-.77 0-1.63-.86-1.63-1.62"/><path d="M7.5 5.8L11.88 8L7.5 10.2zm-7-.3v5m0-2H2a1.5 1.5 0 0 0 0-3H.5"/></g>',
}
+61
View File
@@ -0,0 +1,61 @@
import type { Plugin } from 'prettier'
/** prettier 各格式的 parser 与插件加载器;插件按需动态 import,各自成 chunk */
const PRETTIER_CFG: Record<string, { parser: string; plugins: (() => Promise<unknown>)[] }> = {
yaml: { parser: 'yaml', plugins: [() => import('prettier/plugins/yaml')] },
yml: { parser: 'yaml', plugins: [() => import('prettier/plugins/yaml')] },
css: { parser: 'css', plugins: [() => import('prettier/plugins/postcss')] },
md: { parser: 'markdown', plugins: [() => import('prettier/plugins/markdown')] },
html: {
parser: 'html',
plugins: [
() => import('prettier/plugins/html'),
() => import('prettier/plugins/postcss'),
() => import('prettier/plugins/babel'),
() => import('prettier/plugins/estree'),
],
},
htm: {
parser: 'html',
plugins: [
() => import('prettier/plugins/html'),
() => import('prettier/plugins/postcss'),
() => import('prettier/plugins/babel'),
() => import('prettier/plugins/estree'),
],
},
js: {
parser: 'babel',
plugins: [() => import('prettier/plugins/babel'), () => import('prettier/plugins/estree')],
},
ts: {
parser: 'babel-ts',
plugins: [() => import('prettier/plugins/babel'), () => import('prettier/plugins/estree')],
},
xml: { parser: 'xml', plugins: [() => import('@prettier/plugin-xml')] },
svg: { parser: 'xml', plugins: [() => import('@prettier/plugin-xml')] },
}
/** 该扩展名是否支持一键美化 */
export function canFormat(ext: string): boolean {
return ext === 'json' || ext in PRETTIER_CFG
}
/** 解开插件模块的 default 包装(prettier 插件 ESM 互操作) */
function unwrap(m: unknown): Plugin {
const obj = m as { default?: Plugin }
return obj.default ?? (m as Plugin)
}
/**
* 格式化源码;json 用原生 JSON 美化,其余经 prettier standalone。
* 不支持的格式返回 null;语法错误抛异常由调用方提示。
*/
export async function formatSource(text: string, ext: string): Promise<string | null> {
if (ext === 'json') return JSON.stringify(JSON.parse(text), null, 2)
const cfg = PRETTIER_CFG[ext]
if (!cfg) return null
const { format } = await import('prettier/standalone')
const plugins = await Promise.all(cfg.plugins.map((p) => p().then(unwrap)))
return format(text, { parser: cfg.parser, plugins })
}
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed, defineAsyncComponent } from 'vue'
/** office/pdf 渲染:vue-office 组件按需异步加载,各自独立 chunk,不预览不下载 */
const props = defineProps<{
kind: 'docx' | 'xlsx' | 'pdf' | 'pptx'
data: ArrayBuffer
}>()
const emit = defineEmits<{ error: [string] }>()
const comps = {
docx: defineAsyncComponent(async () => {
await import('@vue-office/docx/lib/index.css')
return (await import('@vue-office/docx')).default
}),
xlsx: defineAsyncComponent(async () => {
await import('@vue-office/excel/lib/index.css')
return (await import('@vue-office/excel')).default
}),
pdf: defineAsyncComponent(() => import('@vue-office/pdf').then((m) => m.default)),
pptx: defineAsyncComponent(() => import('@vue-office/pptx').then((m) => m.default)),
}
const comp = computed(() => comps[props.kind])
</script>
<template>
<component
:is="comp"
:src="data"
class="office-host"
@error="emit('error', '文档渲染失败,请下载查看')"
/>
</template>
<style scoped>
.office-host {
height: 100%;
}
</style>
@@ -0,0 +1,179 @@
<script setup lang="ts">
import { NSpin } from 'naive-ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { extOf } from '@/components/objectstorage/codeHighlight'
/** 文本编辑器:CodeMirror 6,行号 / 语法高亮 / 搜索;
* 核心与语言包全部动态 import 独立 chunk,不进编辑不下载。
* 须放在定高容器内(内部自滚动)。 */
const props = defineProps<{
modelValue: string
objectName: string
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
type CmExtension = import('@codemirror/state').Extension
type CmEditorView = typeof import('@codemirror/view').EditorView
const host = ref<HTMLElement | null>(null)
const booting = ref(true)
const bootError = ref('')
let view: import('@codemirror/view').EditorView | null = null
let EV: CmEditorView | null = null
let editableComp: import('@codemirror/state').Compartment | null = null
onMounted(() => void boot())
onBeforeUnmount(() => view?.destroy())
async function boot() {
try {
const [{ basicSetup }, { EditorView }, { Compartment }, lang, theme] = await Promise.all([
import('codemirror'),
import('@codemirror/view'),
import('@codemirror/state'),
loadLanguage(extOf(props.objectName)),
themeExtensions(),
])
EV = EditorView
editableComp = new Compartment()
view = new EditorView({
parent: host.value!,
doc: props.modelValue,
extensions: [
basicSetup,
EditorView.lineWrapping,
...(lang ? [lang] : []),
...theme,
editableComp.of(EditorView.editable.of(!props.disabled)),
EditorView.updateListener.of((u) => {
if (u.docChanged) emit('update:modelValue', u.state.doc.toString())
}),
],
})
} catch (e) {
bootError.value = e instanceof Error ? e.message : '编辑器加载失败'
} finally {
booting.value = false
}
}
/** 主题与语法配色引用设计 token(明暗自适应),语义与全局 .code-hl 一致 */
async function themeExtensions(): Promise<CmExtension[]> {
const [{ HighlightStyle, syntaxHighlighting }, { tags: t }, { EditorView }] = await Promise.all([
import('@codemirror/language'),
import('@lezer/highlight'),
import('@codemirror/view'),
])
const hl = HighlightStyle.define([
{ tag: [t.keyword, t.operatorKeyword, t.modifier], color: 'var(--color-warn)' },
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--color-ok)' },
{ tag: [t.number, t.bool, t.null, t.atom], color: 'var(--color-accent)' },
{ tag: [t.comment, t.meta], color: 'var(--color-ink-3)', fontStyle: 'italic' },
{ tag: [t.propertyName, t.attributeName], color: 'var(--color-info)' },
{ tag: [t.typeName, t.className, t.tagName], color: 'var(--color-accent)' },
{ tag: t.heading, color: 'var(--color-ink)', fontWeight: '600' },
{ tag: t.link, color: 'var(--color-accent)' },
])
const theme = EditorView.theme({
'&': { height: '100%', fontSize: '12px', backgroundColor: 'transparent' },
'.cm-scroller': {
fontFamily: "ui-monospace, 'SF Mono', Menlo, Consolas, monospace",
lineHeight: '1.65',
overflow: 'auto',
},
'.cm-gutters': {
backgroundColor: 'var(--color-wash)',
color: 'var(--color-ink-3)',
border: 'none',
},
'.cm-activeLineGutter': { backgroundColor: 'var(--color-wash)' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--color-wash) 55%, transparent)' },
'&.cm-focused': { outline: 'none' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 18%, transparent)',
},
})
// 不带 fallback:作为正式高亮器压过 basicSetup 内置的 defaultHighlightStyle(其为 fallback)
return [theme, syntaxHighlighting(hl)]
}
/** 扩展名 → CodeMirror 语言扩展;不在表内按纯文本编辑 */
const CM_LANG: Record<string, () => Promise<CmExtension>> = {
json: () => import('@codemirror/lang-json').then((m) => m.json()),
yaml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
yml: () => import('@codemirror/lang-yaml').then((m) => m.yaml()),
py: () => import('@codemirror/lang-python').then((m) => m.python()),
js: () => import('@codemirror/lang-javascript').then((m) => m.javascript()),
ts: () => import('@codemirror/lang-javascript').then((m) => m.javascript({ typescript: true })),
xml: () => import('@codemirror/lang-xml').then((m) => m.xml()),
svg: () => import('@codemirror/lang-xml').then((m) => m.xml()),
html: () => import('@codemirror/lang-html').then((m) => m.html()),
htm: () => import('@codemirror/lang-html').then((m) => m.html()),
css: () => import('@codemirror/lang-css').then((m) => m.css()),
md: () => import('@codemirror/lang-markdown').then((m) => m.markdown()),
sql: () => import('@codemirror/lang-sql').then((m) => m.sql()),
go: () => import('@codemirror/lang-go').then((m) => m.go()),
sh: () => import('@codemirror/legacy-modes/mode/shell').then((m) => streamOf(m.shell)),
ini: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
conf: () =>
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
cfg: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
env: () => import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
properties: () =>
import('@codemirror/legacy-modes/mode/properties').then((m) => streamOf(m.properties)),
toml: () => import('@codemirror/legacy-modes/mode/toml').then((m) => streamOf(m.toml)),
}
async function streamOf<T>(def: import('@codemirror/language').StreamParser<T>) {
const { StreamLanguage } = await import('@codemirror/language')
return StreamLanguage.define(def)
}
async function loadLanguage(ext: string): Promise<CmExtension | null> {
const load = CM_LANG[ext]
if (!load) return null
try {
return await load()
} catch {
return null
}
}
watch(
() => props.modelValue,
(v) => {
// 外部改写(如美化格式)同步进编辑器;自身输入产生的 emit 值相等,跳过
if (view && v !== view.state.doc.toString())
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: v } })
},
)
watch(
() => props.disabled,
(d) => {
if (view && editableComp && EV)
view.dispatch({ effects: editableComp.reconfigure(EV.editable.of(!d)) })
},
)
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="booting" class="flex flex-1 items-center justify-center">
<NSpin size="small" />
</div>
<div v-else-if="bootError" class="flex flex-1 items-center justify-center text-xs text-err">
{{ bootError }}
</div>
<div ref="host" class="cm-host min-h-0 flex-1" :class="booting || bootError ? 'hidden' : ''"></div>
</div>
</template>
<style scoped>
.cm-host :deep(.cm-editor) {
height: 100%;
}
</style>
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { NButton } from 'naive-ui'
import { onBeforeUnmount, computed, ref, watch } from 'vue'
import { highlightCode, highlightSource } from '@/components/objectstorage/codeHighlight'
/** 文本类预览:hljs 高亮;md/svg 支持「渲染 ↔ 代码」双形态 */
const props = defineProps<{
text: string
ext: string
truncated?: boolean
}>()
const renderMode = ref<'render' | 'code'>('render')
const mdHtml = ref('')
const hlHtml = ref('')
const svgUrl = ref('')
const switchable = computed(() => props.ext === 'md' || props.ext === 'svg')
watch(() => [props.text, props.ext], () => void refresh(), { immediate: true })
onBeforeUnmount(revokeSvg)
async function refresh() {
renderMode.value = 'render'
mdHtml.value = ''
revokeSvg()
if (props.ext === 'svg')
svgUrl.value = URL.createObjectURL(new Blob([props.text], { type: 'image/svg+xml' }))
if (props.ext === 'md') void renderMd()
hlHtml.value = (await highlightSource(props.text, props.ext)) ?? ''
}
function revokeSvg() {
if (svgUrl.value) URL.revokeObjectURL(svgUrl.value)
svgUrl.value = ''
}
/** markdown 渲染:marked 转 HTML(围栏代码块经 hljs 着色)后 DOMPurify 消毒;库按需加载独立 chunk */
async function renderMd() {
try {
const [{ Marked }, { markedHighlight }, { default: DOMPurify }] = await Promise.all([
import('marked'),
import('marked-highlight'),
import('dompurify'),
])
const md = new Marked(
markedHighlight({
async: true,
// 未命中语言返回 null,回退 marked 默认转义渲染
highlight: (code, lang) => highlightCode(code, lang),
}),
)
mdHtml.value = DOMPurify.sanitize(await md.parse(props.text, { async: true }))
} catch {
renderMode.value = 'code'
}
}
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="switchable" class="mb-2 flex flex-none justify-end gap-1">
<NButton
size="tiny"
:quaternary="renderMode !== 'render'"
:type="renderMode === 'render' ? 'primary' : 'default'"
@click="renderMode = 'render'"
>
渲染
</NButton>
<NButton
size="tiny"
:quaternary="renderMode !== 'code'"
:type="renderMode === 'code' ? 'primary' : 'default'"
@click="renderMode = 'code'"
>
代码
</NButton>
</div>
<div class="min-h-0 flex-1 overflow-auto rounded bg-white">
<img
v-if="ext === 'svg' && renderMode === 'render' && svgUrl"
:src="svgUrl"
alt="svg 预览"
class="mx-auto max-h-full object-contain p-4"
/>
<!-- eslint-disable vue/no-v-html -- mdHtml DOMPurify 消毒;hljs 输出已转义原文本 -->
<div
v-else-if="ext === 'md' && renderMode === 'render' && mdHtml"
class="md-preview code-hl px-4 py-3"
v-html="mdHtml"
></div>
<pre
v-else-if="hlHtml"
class="code-hl mono m-0 p-3 text-xs leading-relaxed break-all whitespace-pre-wrap"
v-html="hlHtml"
></pre>
<!-- eslint-enable vue/no-v-html -->
<pre
v-else
class="mono m-0 p-3 text-xs leading-relaxed break-all whitespace-pre-wrap"
>{{ text }}</pre>
</div>
<div v-if="truncated" class="mt-1.5 flex-none text-xs text-warn">
内容过长已截断,完整内容请下载查看
</div>
</div>
</template>
<style scoped>
/* markdown 渲染态的基础排版;内容经 v-html 注入须 :deep 选中。
hljs 高亮配色为全局 .code-hl(main.css),与编辑器共用 */
.md-preview {
font-size: 13px;
line-height: 1.7;
color: var(--color-ink);
}
.md-preview :deep(h1),
.md-preview :deep(h2),
.md-preview :deep(h3),
.md-preview :deep(h4) {
margin: 0.9em 0 0.4em;
font-weight: 600;
line-height: 1.4;
}
.md-preview :deep(h1) {
font-size: 1.3em;
}
.md-preview :deep(h2) {
font-size: 1.15em;
}
.md-preview :deep(h3) {
font-size: 1.05em;
}
.md-preview :deep(p),
.md-preview :deep(ul),
.md-preview :deep(ol),
.md-preview :deep(blockquote),
.md-preview :deep(pre),
.md-preview :deep(table) {
margin: 0.5em 0;
}
.md-preview :deep(ul),
.md-preview :deep(ol) {
padding-left: 1.4em;
list-style: revert;
}
.md-preview :deep(code) {
background: var(--color-wash);
border-radius: 4px;
padding: 0.1em 0.35em;
font-size: 0.92em;
}
.md-preview :deep(pre) {
background: var(--color-wash);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
}
.md-preview :deep(pre code) {
background: none;
padding: 0;
}
.md-preview :deep(blockquote) {
border-left: 3px solid var(--color-line);
padding-left: 0.8em;
color: var(--color-ink-2);
}
.md-preview :deep(a) {
color: var(--color-accent);
text-decoration: underline;
}
.md-preview :deep(table) {
border-collapse: collapse;
}
.md-preview :deep(th),
.md-preview :deep(td) {
border: 1px solid var(--color-line);
padding: 4px 10px;
}
.md-preview :deep(img) {
max-width: 100%;
}
</style>
+3 -4
View File
@@ -4,7 +4,6 @@ import {
NDataTable,
NInput,
NModal,
NPopconfirm,
NRadioButton,
NRadioGroup,
type DataTableColumns,
@@ -19,6 +18,7 @@ import {
probeProxy,
updateProxy,
} from '@/api/proxies'
import ConfirmPop from '@/components/ConfirmPop.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FootNote from '@/components/FootNote.vue'
import FormField from '@/components/FormField.vue'
@@ -257,12 +257,11 @@ const columns: DataTableColumns<ProxyInfo> = [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openTenants(row) }, () => '关联租户'),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, () => '编辑'),
h(
NPopconfirm,
{ onPositiveClick: () => remove(row) },
ConfirmPop,
{ title: '删除该代理?', content: '删除后不可恢复。', confirmText: '删除', onConfirm: () => remove(row) },
{
trigger: () =>
h(NButton, { size: 'tiny', type: 'error', quaternary: true, disabled: row.usedBy > 0 }, () => '删除'),
default: () => '删除后不可恢复,确认?',
},
),
]),
+6 -6
View File
@@ -114,17 +114,17 @@ function openRepo() {
<!-- 运行状态:运行时长随查看自增,资源指标为进程自启动累计口径 -->
<div v-if="info?.resources" class="panel">
<div
class="flex items-center justify-between gap-3 border-b border-line-soft px-5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
class="flex items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5 max-md:flex-col max-md:items-start max-md:gap-1"
>
<div class="flex items-center gap-2">
<span class="live-dot"></span>
<span class="text-[13px] font-semibold">运行状态</span>
<span class="text-sm font-semibold">运行状态</span>
</div>
<span class="text-xs text-ink-3">
{{ fmtTime(info.startedAt) }} 启动 · 指标为进程自启动累计口径
</span>
</div>
<div class="px-5 pt-[22px] pb-5">
<div class="px-4.5 pt-[22px] pb-5">
<div class="text-xs font-medium text-ink-2">运行时间</div>
<div class="mt-1.5 text-[38px] leading-[1.1] font-semibold tracking-[-0.6px]">
<template v-for="p in uptimeParts" :key="p.u"
@@ -132,9 +132,9 @@ function openRepo() {
>
</div>
</div>
<div class="mx-5 h-px bg-line-soft"></div>
<div class="mx-4.5 h-px bg-line-soft"></div>
<div class="grid grid-cols-4 max-md:grid-cols-2">
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-5 pt-4 pb-[18px]">
<div v-for="c in statCells" :key="c.label" class="statcell flex flex-col gap-[5px] px-4.5 pt-4 pb-[18px]">
<div class="text-xs font-medium text-ink-2">{{ c.label }}</div>
<div class="text-[26px] leading-[1.15] font-semibold tracking-[-0.3px]">
<span>{{ c.value }}</span
@@ -146,7 +146,7 @@ function openRepo() {
</div>
<!-- GitHub 仓库 -->
<div class="panel flex items-center gap-3.5 px-5 py-4">
<div class="panel flex items-center gap-3.5 px-4.5 py-4">
<span class="flex h-10 w-10 flex-none items-center justify-center rounded-[9px] bg-wash">
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="currentColor">
<path
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NInput, NModal, NPopconfirm, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { NButton, NInput, NModal, NQrCode, NSpin, NSwitch } from 'naive-ui'
import { computed, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -18,11 +18,13 @@ import {
updateOAuthSettings,
updatePasswordLogin,
} from '@/api/auth'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
import { useAppStore } from '@/stores/app'
import { darkTokens, tokens } from '@/theme/tokens'
import { useAuthStore } from '@/stores/auth'
import { fmtTime } from '@/composables/useFormat'
import type { SessionRefresh, TotpSetup, UpdateOAuthRequest } from '@/types/api'
@@ -495,12 +497,17 @@ async function deleteProvider(p: ProviderType) {
</div>
<div class="mt-0.5 text-xs text-ink-3">绑定于 {{ fmtTime(it.createdAt) }}</div>
</div>
<NPopconfirm @positive-click="removeIdentity(it.id)">
<ConfirmPop
:title="`解绑 ${displayNameOf(it.provider)} 身份?`"
content="解绑后该身份无法再登录面板。"
kind="plain"
confirm-text="解绑"
:on-confirm="() => removeIdentity(it.id)"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary>解绑</NButton>
</template>
解绑后该身份无法再登录面板,确认?
</NPopconfirm>
</ConfirmPop>
</div>
<div
@@ -575,12 +582,16 @@ async function deleteProvider(p: ProviderType) {
>
{{ row.disabled ? '启用' : '禁用' }}
</NButton>
<NPopconfirm @positive-click="deleteProvider(row.provider)">
<ConfirmPop
title="删除该登录方式"
content="将清空全部配置( Secret),不可恢复"
confirm-text="删除"
:on-confirm="() => deleteProvider(row.provider)"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary>删除</NButton>
</template>
删除将清空该方式全部配置( Secret),不可恢复;确认?
</NPopconfirm>
</ConfirmPop>
</td>
</tr>
</tbody>
@@ -745,7 +756,7 @@ async function deleteProvider(p: ProviderType) {
:value="totpPending.otpauthUri"
:size="160"
:padding="0"
:color="app.dark ? '#f5f4ed' : '#141413'"
:color="app.dark ? darkTokens.ink : tokens.ink"
background-color="transparent"
error-correction-level="M"
/>
+3 -7
View File
@@ -5,6 +5,7 @@ import { computed, h, onMounted, reactive, ref, watch } from 'vue'
import { getAuditEventDetail, listAuditEvents } from '@/api/tenant'
import DetailFieldList, { type DetailRow } from '@/components/DetailFieldList.vue'
import DetailHero, { type HeroChip } from '@/components/DetailHero.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import JsonBlock from '@/components/JsonBlock.vue'
import StatusBadge from '@/components/StatusBadge.vue'
@@ -267,7 +268,7 @@ const columns: DataTableColumns<AuditEvent> = [
<template>
<div class="panel">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3">
<div class="flex flex-wrap items-center gap-2.5 border-b border-line-soft px-4.5 py-3.5">
<div class="text-sm font-semibold">审计日志</div>
<StatusBadge v-if="exhausted" kind="run" label="已加载全部保留期数据" :dot="false" />
<div class="flex-1" />
@@ -285,12 +286,7 @@ const columns: DataTableColumns<AuditEvent> = [
<NButton size="small" :loading="loading" @click="load()">刷新</NButton>
</div>
<div
v-if="error"
class="px-5 py-10 text-center text-[13px] text-ink-3"
>
{{ error }}
</div>
<EmptyCard v-if="error" title="审计日志加载失败" :note="error" />
<NDataTable
v-else
:columns="columns"
+141 -41
View File
@@ -2,49 +2,122 @@
import { NSelect, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { getCosts } from '@/api/analytics'
import { getCosts, type CostQuery } from '@/api/analytics'
import CostChart from '@/components/CostChart.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import { useAsync } from '@/composables/useAsync'
const props = defineProps<{ cfgId: number }>()
const rangeDays = ref(14)
/** 'today' 走小时粒度(本地零点起);数字为最近 N 天(天粒度) */
const range = ref<'today' | 14 | 30>(14)
const rangeOptions = [
{ label: '今天', value: 'today' as const },
{ label: '近 14 天', value: 14 },
{ label: '近 30 天', value: 30 },
]
const costs = useAsync(() => getCosts(props.cfgId, { granularity: 'DAILY', groupBy: 'service' }))
watch(rangeDays, () => void costs.run())
const daily = computed(() => {
const byDay = new Map<string, number>()
for (const item of costs.data.value ?? []) {
const day = item.timeStart.slice(5, 10)
byDay.set(day, (byDay.get(day) ?? 0) + item.computedAmount)
/** 一次复合分组查询(service,skuName):总额/曲线/服务/产品全部本地聚合,不加请求 */
function buildQuery(): CostQuery {
const q: CostQuery = { groupBy: 'service,skuName' }
if (range.value === 'today') {
const midnight = new Date()
midnight.setHours(0, 0, 0, 0)
q.granularity = 'HOURLY'
q.startTime = midnight.toISOString()
} else {
q.granularity = 'DAILY'
q.startTime = new Date(Date.now() - range.value * 86400e3).toISOString()
}
const labels = [...byDay.keys()].sort()
return { labels, values: labels.map((l) => +(byDay.get(l) ?? 0).toFixed(2)) }
return q
}
const costs = useAsync(() => getCosts(props.cfgId, buildQuery()))
watch(range, () => void costs.run())
/** 时间桶标签:今天按本地小时(HH:00),天粒度按 MM-DD(UTC 日) */
function bucketLabel(timeStart: string): string {
if (range.value !== 'today') return timeStart.slice(5, 10)
return `${String(new Date(timeStart).getHours()).padStart(2, '0')}:00`
}
/** 完整时间轴:Usage API 对无出账记录的时段不返回行,不补零桶会断轴缺刻度 */
function fullLabels(): string[] {
if (range.value === 'today') {
const hours = new Date().getHours()
return Array.from({ length: hours + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`)
}
const days = range.value
return Array.from({ length: days + 1 }, (_, i) =>
new Date(Date.now() - (days - i) * 86400e3).toISOString().slice(5, 10),
)
}
const series = computed(() => {
const byBucket = new Map<string, number>()
for (const item of costs.data.value ?? []) {
const key = bucketLabel(item.timeStart)
byBucket.set(key, (byBucket.get(key) ?? 0) + item.computedAmount)
}
const labels = fullLabels()
return { labels, values: labels.map((l) => +(byBucket.get(l) ?? 0).toFixed(2)) }
})
const total = computed(() => daily.value.values.reduce((s, v) => s + v, 0))
const total = computed(() => series.value.values.reduce((s, v) => s + v, 0))
const byService = computed(() => {
const map = new Map<string, number>()
interface ProductRow {
name: string
amount: number
pct: number
}
interface ServiceRow {
name: string
amount: number
pct: number
barWidth: string
products: ProductRow[]
}
/** 服务榜单 + 各服务内产品(SKU)明细,均按金额倒序 */
const byService = computed<ServiceRow[]>(() => {
const svcTotal = new Map<string, number>()
const skuTotal = new Map<string, Map<string, number>>()
for (const item of costs.data.value ?? []) {
map.set(item.groupValue, (map.get(item.groupValue) ?? 0) + item.computedAmount)
const svc = item.groupValue || '(未知服务)'
svcTotal.set(svc, (svcTotal.get(svc) ?? 0) + item.computedAmount)
const skus = skuTotal.get(svc) ?? new Map<string, number>()
const sku = item.subValue || '(未细分)'
skus.set(sku, (skus.get(sku) ?? 0) + item.computedAmount)
skuTotal.set(svc, skus)
}
const rows = [...map.entries()].sort((a, b) => b[1] - a[1])
const rows = [...svcTotal.entries()].sort((a, b) => b[1] - a[1])
const max = rows[0]?.[1] ?? 1
return rows.map(([name, amount]) => ({
name,
amount,
pct: total.value ? Math.round((amount / total.value) * 100) : 0,
barWidth: `${Math.round((amount / max) * 100)}%`,
products: [...(skuTotal.get(name) ?? new Map<string, number>()).entries()]
.sort((a, b) => b[1] - a[1])
.map(([sku, amt]) => ({
name: sku,
amount: amt,
pct: amount ? Math.round((amt / amount) * 100) : 0,
})),
}))
})
// ---- 展开态:按服务名记录,切范围后清空 ----
const expanded = ref(new Set<string>())
watch(range, () => (expanded.value = new Set()))
function toggle(name: string) {
const next = new Set(expanded.value)
if (next.has(name)) next.delete(name)
else next.add(name)
expanded.value = next
}
</script>
<template>
@@ -53,7 +126,7 @@ const byService = computed(() => {
<div class="text-sm font-semibold">成本分析</div>
<div class="flex-1" />
<div class="w-28">
<NSelect v-model:value="rangeDays" size="small" :options="rangeOptions" />
<NSelect v-model:value="range" size="small" :options="rangeOptions" />
</div>
</div>
@@ -64,43 +137,70 @@ const byService = computed(() => {
<div class="flex flex-wrap items-baseline gap-2.5 px-4.5 pt-3.5">
<div class="text-[26px] font-semibold tabular-nums">${{ total.toFixed(2) }}</div>
<div class="text-[12.5px] text-ink-3">
{{ daily.labels[0] }} {{ daily.labels.at(-1) }} 合计
{{
range === 'today'
? '今天 0 点至今合计(小时粒度)'
: `${series.labels[0]}${series.labels.at(-1)} 合计`
}}
</div>
</div>
<div class="px-3 pb-1">
<CostChart :labels="daily.labels" :values="daily.values" />
<CostChart :labels="series.labels" :values="series.values" />
</div>
<div class="border-t border-line-soft px-4.5 py-3">
<div class="mb-2 flex items-center justify-between">
<div class="text-[13px] font-semibold">按服务拆分</div>
<div class="text-[12.5px] text-ink-3">本期合计 ${{ total.toFixed(2) }}</div>
<div class="text-[12.5px] text-ink-3">点击服务行展开产品 · 本期合计 ${{ total.toFixed(2) }}</div>
</div>
<div
v-for="svc in byService"
:key="svc.name"
class="flex items-center gap-3 py-1.5 max-md:gap-2"
>
<div class="w-32 flex-none truncate text-[13px] max-md:w-24" :title="svc.name">
{{ svc.name }}
<template v-for="svc in byService" :key="svc.name">
<div
class="flex cursor-pointer items-center gap-3 rounded py-1.5 select-none hover:bg-wash/70 max-md:gap-2"
@click="toggle(svc.name)"
>
<span
class="w-3 flex-none text-center text-[10px] text-ink-3 transition-transform"
:class="expanded.has(svc.name) ? 'rotate-90' : ''"
>
</span>
<div class="w-32 flex-none truncate text-[13px] max-md:w-24" :title="svc.name">
{{ svc.name }}
</div>
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-line-soft">
<div class="h-full rounded-full bg-chart" :style="{ width: svc.barWidth }" />
</div>
<div class="w-17 flex-none text-right text-[13px] tabular-nums">
${{ svc.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">
{{ svc.pct }}%
</div>
</div>
<div class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-line-soft">
<div class="h-full rounded-full bg-chart" :style="{ width: svc.barWidth }" />
<div v-if="expanded.has(svc.name)" class="mb-1 ml-3 border-l border-line-soft pl-4">
<div
v-for="p in svc.products"
:key="p.name"
class="flex items-center gap-3 py-1 max-md:gap-2"
>
<div class="min-w-0 flex-1 truncate text-[12.5px] text-ink-2" :title="p.name">
{{ p.name }}
</div>
<div class="w-17 flex-none text-right text-[12.5px] tabular-nums">
${{ p.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">
{{ p.pct }}%
</div>
</div>
</div>
<div class="w-17 flex-none text-right text-[13px] tabular-nums">
${{ svc.amount.toFixed(2) }}
</div>
<div class="w-10 flex-none text-right text-xs tabular-nums text-ink-3">{{ svc.pct }}%</div>
</div>
</template>
</div>
</template>
<EmptyCard
v-else
title="暂无成本数据"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天"
:title="range === 'today' ? '今天暂无成本数据' : '暂无成本数据'"
note="试用租户无消费时成本为空;Usage API 数据通常延迟数小时至一天,「今天」视图可能滞后"
/>
<FootNote>
每日 03:30 自动同步 Usage API · 本地快照 · 免费类别租户不参与成本同步 · 点击刷新可即时拉取
</FootNote>
</div>
</template>
+7 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, NSwitch, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import {
@@ -16,6 +16,7 @@ import StatusBadge from '@/components/StatusBadge.vue'
import IdpFormModal from '@/components/tenant/IdpFormModal.vue'
import { useAsync } from '@/composables/useAsync'
import type { IdentityProvider, SignOnRule } from '@/types/api'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
@@ -121,15 +122,17 @@ const ruleColumns: DataTableColumns<SignOnRule> = [
row.builtIn
? h('span', { class: 'text-xs text-ink-3' }, '内置')
: h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '删除该免 MFA 规则?',
content: '删除后经此 IdP 登录将重新要求 MFA。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteSignOnExemption(props.cfgId, row.id, props.domainId), '已删除免 MFA 规则并恢复优先级'),
},
{
trigger: () =>
h(NButton, { size: 'tiny', quaternary: true, type: 'error' }, { default: () => '删除' }),
default: () => '删除该免 MFA 规则?删除后经此 IdP 登录将重新要求 MFA',
},
),
},
@@ -0,0 +1,129 @@
<script setup lang="ts">
import { NButton, NModal, NSpin } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { fetchInvoicePdf, listInvoiceLines, saveInvoicePdf } from '@/api/billing'
import StatusBadge from '@/components/StatusBadge.vue'
import { useToast } from '@/composables/useToast'
import { invoiceDate, invoiceStatusMeta } from '@/components/tenant/invoiceStatus'
import { useAsync } from '@/composables/useAsync'
import { fmtMoney } from '@/composables/useFormat'
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 lines = useAsync(async () => {
const inv = props.invoice
return inv ? listInvoiceLines(props.cfgId, inv.internalId) : []
}, false)
watch(
() => props.show,
(v) => {
if (v && props.invoice) void lines.run()
},
)
const meta = computed(() => (props.invoice ? invoiceStatusMeta(props.invoice) : null))
const total = computed(() => (lines.data.value ?? []).reduce((s, l) => s + l.total, 0))
const payable = computed(() => !!props.invoice && props.invoice.isPayable && !props.invoice.isPaid)
const downloading = ref(false)
async function onPdf() {
const inv = props.invoice
if (!inv) return
downloading.value = true
try {
saveInvoicePdf(await fetchInvoicePdf(props.cfgId, inv.internalId), inv.number || inv.internalId)
} catch (e) {
message.error(e instanceof Error ? e.message : 'PDF 下载失败')
} finally {
downloading.value = false
}
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="invoice ? `发票 ${invoice.number || invoice.id}` : '发票明细'"
:style="{ width: '640px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<template v-if="invoice">
<div class="rounded-md bg-wash px-4 py-3">
<div class="flex items-baseline justify-between gap-3">
<div class="text-[24px] font-semibold tabular-nums">
{{ fmtMoney(invoice.amount, invoice.currency) }}
</div>
<StatusBadge v-if="meta" :kind="meta.kind" :label="meta.label" />
</div>
<div class="mt-1.5 text-xs text-ink-3">
{{ invoice.type || 'USAGE' }} · 开票 {{ invoiceDate(invoice.timeInvoice) }} · 到期
{{ invoiceDate(invoice.timeDue) }}
</div>
<div v-if="invoice.isPaymentFailed" class="mt-1.5 text-xs text-err">
上次自动扣款失败,请检查付款方式后手动支付
</div>
</div>
<div class="mt-4 mb-1.5 text-[13px] font-semibold">费用明细</div>
<div class="max-h-[46vh] overflow-y-auto">
<div v-if="lines.loading.value" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<div v-else-if="lines.error.value" class="py-6 text-center text-xs text-err">
{{ lines.error.value }}
</div>
<template v-else>
<div
v-for="(l, i) in lines.data.value ?? []"
:key="i"
class="flex items-center gap-3 border-b border-line-soft py-2 last:border-b-0"
>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px]" :title="l.product">{{ l.product }}</div>
<div class="mt-0.5 text-xs text-ink-3">
{{ invoiceDate(l.timeStart) }} ~ {{ invoiceDate(l.timeEnd) }}
</div>
</div>
<div class="flex-none text-right text-xs text-ink-3 tabular-nums">
{{ l.quantity.toLocaleString() }} × {{ l.unitPrice }}
</div>
<div class="w-20 flex-none text-right text-[13px] tabular-nums">
{{ fmtMoney(l.total, l.currency || invoice.currency) }}
</div>
</div>
<div v-if="!lines.data.value?.length" class="py-6 text-center text-xs text-ink-3">
该发票暂无费用行
</div>
</template>
</div>
<div
v-if="lines.data.value?.length"
class="flex items-center justify-between border-t border-line pt-2.5 text-[13px] font-semibold"
>
<span>合计</span>
<span class="tabular-nums">{{ fmtMoney(total, invoice.currency) }}</span>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-2.5">
<NButton size="small" :loading="downloading" @click="onPdf">下载 PDF</NButton>
<NButton
v-if="payable && invoice"
size="small"
type="primary"
@click="emit('pay', invoice)"
>
立即付款
</NButton>
</div>
</template>
</NModal>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { NInput } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { payInvoice } from '@/api/billing'
import FormModal from '@/components/FormModal.vue'
import { useToast } from '@/composables/useToast'
import { invoiceDate } from '@/components/tenant/invoiceStatus'
import { fmtMoney } from '@/composables/useFormat'
import type { Invoice, PaymentMethod } from '@/types/api'
/** 付款确认:经 OSP Gateway PayInvoice 按订阅默认付款方式即时扣款;
* email 是 API 必填项(接收付款回执),在此向用户收集。 */
const props = defineProps<{
show: boolean
cfgId: number
invoice: Invoice | null
methods: PaymentMethod[] | null
}>()
const emit = defineEmits<{ 'update:show': [boolean]; paid: [] }>()
const message = useToast()
const email = ref('')
const paying = ref(false)
const emailOk = computed(() => /.+@.+\..+/.test(email.value.trim()))
/** 扣款方式展示:优先第一张信用卡,其次任一登记方式 */
const payVia = computed(() => {
const ms = props.methods ?? []
const m = ms.find((x) => x.method === 'CREDIT_CARD') ?? ms[0]
if (!m) return '订阅默认付款方式'
if (m.method === 'CREDIT_CARD') return `${m.cardType || '银行卡'} •••• ${m.lastDigits}`
return `PayPal(${m.email})`
})
watch(
() => props.show,
(v) => {
if (v) paying.value = false
},
)
async function doPay() {
const inv = props.invoice
if (!inv || !emailOk.value) return
paying.value = true
try {
await payInvoice(props.cfgId, inv.internalId, email.value.trim())
message.success('付款已提交,发票状态稍后更新')
emit('update:show', false)
emit('paid')
} catch (e) {
message.error(e instanceof Error ? e.message : '付款失败')
} finally {
paying.value = false
}
}
</script>
<template>
<FormModal
:show="show"
title="支付发票"
:width="440"
submit-text="确认支付"
:submitting="paying"
:submit-disabled="!emailOk"
@update:show="emit('update:show', $event)"
@submit="doPay"
>
<template v-if="invoice">
<div class="rounded-md bg-wash px-4 py-3">
<div class="text-xs text-ink-3">应付金额</div>
<div class="mt-1 text-[24px] font-semibold tabular-nums">
{{ fmtMoney(invoice.amountDue || invoice.amount, invoice.currency) }}
</div>
<div class="mt-1 text-xs text-ink-3">
发票 {{ invoice.number }} · 到期 {{ invoiceDate(invoice.timeDue) }}
</div>
</div>
<div
class="mt-3 flex items-center justify-between rounded-md border border-line-soft px-4 py-2.5 text-[13px]"
>
<span class="text-ink-3">扣款方式</span>
<span class="font-medium">{{ payVia }}</span>
</div>
<div class="mt-3">
<div class="mb-1 text-xs text-ink-3">回执邮箱(必填)</div>
<NInput v-model:value="email" placeholder="billing@example.com" />
</div>
<div class="mt-3 text-xs leading-relaxed text-ink-3">
确认后经 OCI 支付网关按上述方式即时扣款,回执发送至邮箱;提交后发票转入处理中,
入账通常需要数分钟,期间请勿重复支付
</div>
</template>
</FormModal>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { NButton, NModal, NSpin } from 'naive-ui'
import { ref, watch } from 'vue'
import { fetchInvoicePdf, saveInvoicePdf } from '@/api/billing'
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
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 loading = ref(false)
const loadError = ref('')
const pdfData = ref<ArrayBuffer | null>(null)
let blob: Blob | null = null
watch(
() => props.show,
(v) => {
if (v) void load()
},
)
async function load() {
const inv = props.invoice
if (!inv) return
loading.value = true
loadError.value = ''
pdfData.value = null
try {
blob = await fetchInvoicePdf(props.cfgId, inv.internalId)
pdfData.value = await blob.arrayBuffer()
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'PDF 加载失败'
} finally {
loading.value = false
}
}
function download() {
const inv = props.invoice
if (blob && inv) saveInvoicePdf(blob, inv.number || inv.internalId)
}
</script>
<template>
<NModal
:show="show"
preset="card"
:title="invoice ? `发票 ${invoice.number || invoice.id} · PDF` : 'PDF 预览'"
:style="{ width: '860px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<div class="h-[70vh] overflow-auto rounded-lg border border-line-soft bg-wash/50">
<div v-if="loading" class="flex h-full items-center justify-center">
<NSpin size="small" />
</div>
<div
v-else-if="loadError"
class="flex h-full flex-col items-center justify-center gap-2 text-xs text-err"
>
{{ loadError }}
<NButton size="small" @click="load">重试</NButton>
</div>
<div v-else-if="pdfData" class="min-h-full bg-white">
<OfficePreview kind="pdf" :data="pdfData" @error="loadError = $event" />
</div>
</div>
<template #footer>
<div class="flex justify-end">
<NButton size="small" type="primary" :disabled="!pdfData" @click="download">下载</NButton>
</div>
</template>
</NModal>
</template>
+348
View File
@@ -0,0 +1,348 @@
<script setup lang="ts">
import { NButton, NDataTable, NSpin, type DataTableColumns } from 'naive-ui'
import { computed, h, ref } from 'vue'
import { listInvoices, listPaymentMethods } from '@/api/billing'
import { ApiError } from '@/api/request'
import EmptyCard from '@/components/EmptyCard.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import InvoiceDetailModal from '@/components/tenant/InvoiceDetailModal.vue'
import InvoicePayModal from '@/components/tenant/InvoicePayModal.vue'
import InvoicePdfModal from '@/components/tenant/InvoicePdfModal.vue'
import PaymentMethodsModal from '@/components/tenant/PaymentMethodsModal.vue'
import { invoiceDate, invoiceStatusMeta } from '@/components/tenant/invoiceStatus'
import { useAsync } from '@/composables/useAsync'
import { useToast } from '@/composables/useToast'
import { fmtMoney } from '@/composables/useFormat'
import type { Invoice } from '@/types/api'
const props = defineProps<{ cfgId: number }>()
const message = useToast()
// ---- 按年懒加载:初始拉当年(空则自动往前探),底部按钮逐年加载,连空 2 年停 ----
const thisYear = new Date().getFullYear()
const EMPTY_STOP = 2
const rows = ref<Invoice[]>([])
/** 下一个待加载的自然年 */
const nextYear = ref(thisYear)
const exhausted = ref(false)
const initialLoading = ref(true)
const loadingMore = ref(false)
/** 无 osp-gateway 权限时整 tab 转受限卡,不当作普通错误 */
const forbidden = ref(false)
/** 组织被 OCI 账单平台排除(巴西等本地结算体系注册地),账单能力整体不适用 */
const excluded = ref(false)
const loadError = ref('')
let emptyStreak = 0
/** 拉一个自然年并入列表 */
async function loadYear(year: number) {
const batch = await listInvoices(props.cfgId, year)
nextYear.value = year - 1
if (batch.length) {
emptyStreak = 0
rows.value = [...rows.value, ...batch]
} else if (++emptyStreak >= EMPTY_STOP) {
exhausted.value = true
}
}
async function init() {
initialLoading.value = true
forbidden.value = false
excluded.value = false
loadError.value = ''
rows.value = []
nextYear.value = thisYear
exhausted.value = false
emptyStreak = 0
try {
// 自动往前探,直到有数据或连空到停
while (!exhausted.value && !rows.value.length) await loadYear(nextYear.value)
} catch (e) {
// ociCode 判定先于 403:排除类错误可能也以 403 状态返回,不能被权限分支抢走
if (e instanceof ApiError && e.ociCode === 'OrganizationExclusionError') excluded.value = true
else if (e instanceof ApiError && e.status === 403) forbidden.value = true
else loadError.value = e instanceof Error ? e.message : String(e)
} finally {
initialLoading.value = false
}
}
void init()
async function loadMore() {
loadingMore.value = true
try {
await loadYear(nextYear.value)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
loadingMore.value = false
}
}
/** 付款后静默重拉已加载的年份区间,不闪加载态 */
async function refreshSilent() {
const years: number[] = []
for (let y = thisYear; y > nextYear.value; y--) years.push(y)
try {
const batches = await Promise.all(years.map((y) => listInvoices(props.cfgId, y)))
rows.value = batches.flat()
} catch {
/* 静默失败,保留现有数据 */
}
}
/** 非 PAYG 计划(如年度合约/内部账号)没有自助付款方式,单独提示而非报错 */
const methodsUnsupported = ref(false)
/** 付款方式受同一 policy 管制,其余失败静默置空(受限卡已说明原因) */
const methods = useAsync(() =>
listPaymentMethods(props.cfgId).catch((e) => {
if (e instanceof ApiError && e.ociCode === 'InvalidPlanType') methodsUnsupported.value = true
return []
}),
)
// ---- 汇总 ----
/** API 返回序不保证,统一按开票时间倒序 */
const list = computed(() =>
[...rows.value].sort((a, b) => (b.timeInvoice ?? '').localeCompare(a.timeInvoice ?? '')),
)
const currency = computed(() => list.value[0]?.currency || 'USD')
/** 未付余额只计还可操作的欠款;「处理中」已提交扣款,不再计入 */
const unpaidDue = computed(() =>
list.value
.filter((i) => !i.isPaid && (i.status === 'OPEN' || i.status === 'PAST_DUE'))
.reduce((s, i) => s + i.amountDue, 0),
)
const yearTotal = computed(() =>
list.value
.filter((i) => (i.timeInvoice ?? '').startsWith(String(thisYear)))
.reduce((s, i) => s + i.amount, 0),
)
const lastPaid = computed(() => list.value.find((i) => i.isPaid) ?? null)
const methodBrief = computed(() => {
if (methodsUnsupported.value) return '不适用(非 PAYG'
const m = (methods.data.value ?? [])[0]
if (!m) return '未登记'
return m.method === 'CREDIT_CARD' ? `${m.cardType || '银行卡'} •••• ${m.lastDigits}` : 'PayPal'
})
// ---- 弹窗 ----
const detailInv = ref<Invoice | null>(null)
const showDetail = ref(false)
const payInv = ref<Invoice | null>(null)
const showPay = ref(false)
const pdfInv = ref<Invoice | null>(null)
const showPdf = ref(false)
const showMethods = ref(false)
function openDetail(inv: Invoice) {
detailInv.value = inv
showDetail.value = true
}
function openPay(inv: Invoice) {
payInv.value = inv
showPay.value = true
}
function openPdf(inv: Invoice) {
pdfInv.value = inv
showPdf.value = true
}
/** 明细弹窗里点付款:先关明细再开付款确认 */
function payFromDetail(inv: Invoice) {
showDetail.value = false
openPay(inv)
}
const columns = computed<DataTableColumns<Invoice>>(() => [
{
title: '发票号',
key: 'number',
render: (r) =>
h('div', [
h('div', { class: 'text-[13px] font-medium tabular-nums' }, r.number || r.id),
h('div', { class: 'mt-0.5 text-xs text-ink-3' }, r.type || 'USAGE'),
]),
},
{ title: '开票日期', key: 'timeInvoice', width: 110, render: (r) => invoiceDate(r.timeInvoice) },
{
title: '到期日期',
key: 'timeDue',
width: 110,
render: (r) =>
h('span', { class: r.status === 'PAST_DUE' ? 'text-err' : '' }, invoiceDate(r.timeDue)),
},
{
title: '金额',
key: 'amount',
width: 110,
align: 'right',
render: (r) => h('span', { class: 'tabular-nums' }, fmtMoney(r.amount, r.currency)),
},
{
title: '状态',
key: 'status',
width: 100,
render: (r) => {
const m = invoiceStatusMeta(r)
return h(StatusBadge, { kind: m.kind, label: m.label })
},
},
{
title: '操作',
key: 'actions',
width: 170,
align: 'right',
render: (r) =>
h('div', { class: 'flex justify-end gap-1' }, [
r.isPayable && !r.isPaid
? h(
NButton,
{ size: 'tiny', type: 'primary', onClick: (e: MouseEvent) => { e.stopPropagation(); openPay(r) } },
{ default: () => '付款' },
)
: null,
h(
NButton,
{ size: 'tiny', quaternary: true, onClick: (e: MouseEvent) => { e.stopPropagation(); openDetail(r) } },
{ default: () => '明细' },
),
h(
NButton,
{ size: 'tiny', quaternary: true, onClick: (e: MouseEvent) => { e.stopPropagation(); openPdf(r) } },
{ default: () => 'PDF' },
),
]),
},
])
const rowProps = (row: Invoice) => ({
style: 'cursor: pointer',
onClick: () => openDetail(row),
})
</script>
<template>
<div class="flex flex-col gap-4">
<div v-if="initialLoading" class="panel flex items-center justify-center py-16">
<NSpin size="small" />
</div>
<div v-else-if="forbidden" class="panel">
<EmptyCard
title="无账单访问权限"
note="当前 API Key 所属用户无 OSP Gateway 权限;需要租户管理员授予 policy:Allow group Administrators to manage osp-gateway in tenancy"
/>
</div>
<div v-else-if="excluded" class="panel">
<EmptyCard
title="账单服务不适用于该租户"
note="OCI 账单平台(OSP Gateway)排除了该租户所属组织(OrganizationExclusionError),常见于巴西等采用当地结算体系的注册地;发票请前往 Oracle 官方控制台或当地结算渠道查询"
/>
</div>
<div v-else-if="loadError" class="panel flex items-center gap-2 px-4.5 py-3 text-xs text-err">
<span class="min-w-0 flex-1 truncate" :title="loadError">{{ loadError }}</span>
<NButton size="tiny" quaternary @click="init">重试</NButton>
</div>
<div v-else-if="!list.length" class="panel">
<EmptyCard
title="暂无发票"
note="Free Tier / Always Free 用量不产生发票;升级为付费账户后,月度发票将在此展示"
/>
</div>
<template v-else>
<div class="grid grid-cols-4 gap-3 max-md:grid-cols-2">
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">未付余额</div>
<div class="mt-1 text-[20px] font-semibold text-accent tabular-nums">
{{ fmtMoney(unpaidDue, currency) }}
</div>
</div>
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">本年已开票</div>
<div class="mt-1 text-[20px] font-semibold tabular-nums">
{{ fmtMoney(yearTotal, currency) }}
</div>
</div>
<div class="panel px-4 py-3">
<div class="text-xs text-ink-3">上期已付发票</div>
<div class="mt-1 text-[20px] font-semibold tabular-nums">
{{ lastPaid ? fmtMoney(lastPaid.amount, lastPaid.currency) : '—' }}
</div>
</div>
<div
class="panel cursor-pointer px-4 py-3 transition-colors hover:border-accent/45"
title="查看付款方式"
@click="showMethods = true"
>
<div class="flex items-center justify-between text-xs text-ink-3">
<span>默认付款方式</span>
<span class="text-ink-3"></span>
</div>
<div class="mt-1 truncate text-[15px] font-semibold">{{ methodBrief }}</div>
</div>
</div>
<div class="panel">
<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"
:data="list"
:bordered="false"
:bottom-bordered="false"
:row-props="rowProps"
:row-key="(r: Invoice) => r.id"
:scroll-x="720"
size="small"
/>
<div class="flex justify-center border-t border-line-soft py-2">
<NButton
v-if="!exhausted"
size="tiny"
quaternary
:loading="loadingMore"
@click="loadMore"
>
加载 {{ nextYear }} 年发票
</NButton>
<span v-else class="text-xs text-ink-3">已加载全部发票</span>
</div>
</div>
</template>
<InvoiceDetailModal
v-model:show="showDetail"
:cfg-id="cfgId"
:invoice="detailInv"
@pay="payFromDetail"
/>
<InvoicePayModal
v-model:show="showPay"
:cfg-id="cfgId"
:invoice="payInv"
:methods="methods.data.value"
@paid="refreshSilent"
/>
<InvoicePdfModal v-model:show="showPdf" :cfg-id="cfgId" :invoice="pdfInv" />
<PaymentMethodsModal
v-model:show="showMethods"
:methods="methods.data.value"
:loading="methods.loading.value"
:unsupported="methodsUnsupported"
/>
</div>
</template>
+19 -13
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDynamicTags, NPopconfirm, NSpin, NSwitch } from 'naive-ui'
import { NButton, NDynamicTags, NSpin, NSwitch } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import {
@@ -17,6 +17,7 @@ import {
updateIdentitySetting,
updateNotificationRecipients,
} from '@/api/tenant'
import ConfirmPop from '@/components/ConfirmPop.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import PolicyEditModal from '@/components/tenant/PolicyEditModal.vue'
@@ -205,14 +206,17 @@ function policyDesc(p: PasswordPolicy): string {
<span class="h-1.5 w-1.5 rounded-full" :class="relayChip.dot" />
{{ relayChip.label }}
</span>
<NPopconfirm v-if="relayBuilt" @positive-click="destroyRelay">
<ConfirmPop
v-if="relayBuilt"
title="销毁回传链路?"
content="将删除租户内的 Connector、Policy、订阅与 Topic 并撤销回调地址。"
confirm-text="销毁"
:on-confirm="destroyRelay"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary :loading="tearingRelay">
销毁链路
</NButton>
<NButton size="tiny" type="error" quaternary>销毁链路</NButton>
</template>
将删除租户内的 ConnectorPolicy订阅与 Topic 并撤销回调地址,确认销毁?
</NPopconfirm>
</ConfirmPop>
<NButton
v-else
size="small"
@@ -264,14 +268,16 @@ function policyDesc(p: PasswordPolicy): string {
{{ webhook.data.value.webhook?.path }}
</span>
<NButton size="tiny" quaternary class="flex-none" @click="copyWebhookPath">复制</NButton>
<NPopconfirm @positive-click="removeWebhook">
<ConfirmPop
title="撤销 Webhook 地址?"
content="撤销后旧回调地址立即 404,OCI 侧订阅将投递失败。"
confirm-text="撤销"
:on-confirm="removeWebhook"
>
<template #trigger>
<NButton size="tiny" type="error" quaternary class="flex-none" :loading="savingWebhook">
撤销
</NButton>
<NButton size="tiny" type="error" quaternary class="flex-none">撤销</NButton>
</template>
撤销后旧回调地址立即 404,OCI 侧订阅将投递失败,确认撤销?
</NPopconfirm>
</ConfirmPop>
</div>
<div v-else class="mx-4.5 mt-3 flex flex-wrap items-center gap-2.5 rounded-md bg-wash px-3 py-2">
<span class="min-w-0 flex-1 text-xs text-ink-3">
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { NModal, NSpin } from 'naive-ui'
import type { PaymentMethod } from '@/types/api'
/** :
* OSP Gateway 不提供删卡 API,加卡/换卡走 OCI 托管付款页(面板不经手卡号),
* 故这里只展示并引导到 Billing Center */
defineProps<{
show: boolean
methods: PaymentMethod[] | null
loading: boolean
/** 非 PAYG 计划(InvalidPlanType):没有自助付款方式,与「未登记」区分展示 */
unsupported?: boolean
}>()
const emit = defineEmits<{ 'update:show': [boolean] }>()
function iconText(m: PaymentMethod): string {
if (m.method !== 'CREDIT_CARD') return 'PP'
return (m.cardType || 'CARD').slice(0, 4)
}
function title(m: PaymentMethod): string {
if (m.method === 'CREDIT_CARD') return `${m.cardType || '银行卡'} •••• ${m.lastDigits || '????'}`
return m.email || 'PayPal 账户'
}
function subtitle(m: PaymentMethod): string {
if (m.method === 'CREDIT_CARD') {
const exp = m.timeExpiration ? `到期 ${m.timeExpiration.slice(0, 7)}` : ''
return [m.nameOnCard, exp].filter(Boolean).join(' · ')
}
const ba = m.billingAgreement ? `扣款协议 ${m.billingAgreement}` : ''
return [m.payerName, ba].filter(Boolean).join(' · ')
}
</script>
<template>
<NModal
:show="show"
preset="card"
title="付款方式"
:style="{ width: '480px', maxWidth: 'calc(100vw - 24px)' }"
@update:show="emit('update:show', $event)"
>
<div class="-mt-1 mb-2 text-xs text-ink-3">自动扣款与在线付款按订阅默认方式执行</div>
<div v-if="loading" class="flex items-center justify-center py-10">
<NSpin size="small" />
</div>
<template v-else-if="methods?.length">
<div
v-for="(m, i) in methods"
:key="i"
class="flex items-center gap-3 border-b border-line-soft py-3 last:border-b-0"
>
<div
class="flex h-8 w-12 flex-none items-center justify-center rounded border border-line-soft bg-wash text-[10px] font-bold tracking-wide text-ink-2"
>
{{ iconText(m) }}
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{{ title(m) }}</div>
<div class="mt-0.5 truncate text-xs text-ink-3">{{ subtitle(m) }}</div>
</div>
<span
class="flex-none rounded border border-line-soft px-1.5 py-0.5 text-[10.5px] text-ink-3"
>
{{ m.method }}
</span>
</div>
</template>
<div v-else-if="unsupported" class="py-8 text-center text-[12.5px] text-ink-3">
当前订阅计划不属于 PAYG(InvalidPlanType),无自助付款方式;
合约/伙伴结算账号的付款安排请联系 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操作,完成后回本页刷新即可
</div>
</NModal>
</template>
+15 -5
View File
@@ -127,19 +127,29 @@ const baseColumns: DataTableColumns<LimitItem> = [
},
]
/** 字节类配额可达百亿级,列宽须容纳千分位全量数值,禁止折行 */
const availColumns: DataTableColumns<LimitItem> = [
{
title: '已用',
key: 'used',
width: 80,
render: (r) => h('span', { class: 'tabular-nums' }, r.used === undefined ? '—' : String(r.used)),
width: 120,
render: (r) =>
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.used === undefined ? '—' : r.used.toLocaleString('en-US'),
),
},
{
title: '可用',
key: 'available',
width: 80,
width: 120,
render: (r) =>
h('span', { class: 'tabular-nums' }, r.available === undefined ? '—' : String(r.available)),
h(
'span',
{ class: 'tabular-nums whitespace-nowrap' },
r.available === undefined ? '—' : r.available.toLocaleString('en-US'),
),
},
{
title: '用量',
@@ -221,7 +231,7 @@ const columns = computed(() => (withAvail.value ? [...baseColumns, ...availColum
:data="rows"
:loading="limits.loading.value"
:pagination="pagination"
:scroll-x="withAvail ? 660 : 380"
:scroll-x="withAvail ? 760 : 380"
:row-key="(r: LimitItem) => r.name + (r.availabilityDomain ?? '')"
/>
<FootNote>
+3 -5
View File
@@ -5,6 +5,7 @@ import { computed, h, ref } from 'vue'
import { listRegionSubscriptions, listRegions } from '@/api/configs'
import { getSubscription, listLimits, listSubscriptions } from '@/api/tenant'
import AccountTypeBadge from '@/components/AccountTypeBadge.vue'
import EmptyCard from '@/components/EmptyCard.vue'
import FootNote from '@/components/FootNote.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import SubscribeRegionModal from '@/components/tenant/SubscribeRegionModal.vue'
@@ -196,11 +197,8 @@ function subLabel(d: SubscriptionDetail): string {
<NSpin size="small" />
</div>
<div
v-else-if="!subs.data.value?.length"
class="panel px-4.5 py-10 text-center text-[13px] text-ink-3"
>
未获取到订阅信息Organizations API 可能对该租户不可用
<div v-else-if="!subs.data.value?.length" class="panel">
<EmptyCard title="未获取到订阅信息" note="Organizations API 可能对该租户不可用" />
</div>
<div v-for="d in subs.data.value" v-else :key="d.id" class="panel">
+13 -7
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NDataTable, NPopconfirm, useDialog, type DataTableColumns } from 'naive-ui'
import { NButton, NDataTable, useDialog, type DataTableColumns } from 'naive-ui'
import { h, ref } from 'vue'
import { clearUserMfa, deleteUser, deleteUserApiKeys, listUsers, resetUserPassword } from '@/api/tenant'
@@ -9,6 +9,7 @@ import UserFormModal from '@/components/tenant/UserFormModal.vue'
import { fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import type { IamUser } from '@/types/api'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { useToast } from '@/composables/useToast'
const props = defineProps<{ cfgId: number; domainId?: string }>()
@@ -117,25 +118,30 @@ const columns: DataTableColumns<IamUser> = [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => openEdit(row) }, { default: () => '修改' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => resetPwd(row) }, { default: () => '重置密码' }),
h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '清除全部 MFA 因子?',
content: `${row.name} 下次登录需重新注册 MFA。`,
kind: 'warn',
confirmText: '清除',
onConfirm: () =>
act(() => clearUserMfa(props.cfgId, row.id, props.domainId), `已清除 ${row.name} 的全部 MFA`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '清 MFA' }),
default: () => '清除该用户全部 MFA 因子?',
},
),
h(
NPopconfirm,
ConfirmPop,
{
onPositiveClick: () =>
title: '删除全部 API Key',
content: '跳过当前配置使用中的指纹。',
confirmText: '删除',
onConfirm: () =>
act(() => deleteUserApiKeys(props.cfgId, row.id), `已删除 ${row.name} 的 API Key`),
},
{
trigger: () => h(NButton, { size: 'tiny', quaternary: true }, { default: () => '删 Key' }),
default: () => '删除该用户全部 API Key(跳过当前配置使用中的指纹)?',
},
),
row.isCurrentUser
+18
View File
@@ -0,0 +1,18 @@
import type { Invoice } from '@/types/api'
/** 发票状态 → 徽章形态与中文标签(isPaid 优先于 status 判定) */
export function invoiceStatusMeta(inv: Invoice): {
kind: 'warn' | 'term' | 'prov' | 'run' | 'check'
label: string
} {
if (inv.isPaid || inv.status === 'CLOSED') return { kind: 'run', label: '已付' }
if (inv.status === 'PAST_DUE') return { kind: 'term', label: '逾期' }
if (inv.status === 'PAYMENT_SUBMITTED') return { kind: 'prov', label: '处理中' }
if (inv.status === 'OPEN') return { kind: 'warn', label: '未付' }
return { kind: 'check', label: inv.status || '未知' }
}
/** ISO 时间取日期段;发票的开票/到期语义是日期,不做时区换算 */
export function invoiceDate(iso: string | null): string {
return iso ? iso.slice(0, 10) : '—'
}