新增对象存储/账单/保留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
@@ -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>