Files
oci-portal-dash/src/components/objectstorage/ObjectBrowser.vue
T
2026-07-22 16:51:45 +08:00

950 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
NButton,
NDataTable,
NDropdown,
NInput,
NRadio,
NRadioGroup,
NTabPane,
NTabs,
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 StatusBadge from '@/components/StatusBadge.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useIsMobile } from '@/composables/useIsMobile'
import type { Bucket, ObjectSummary } from '@/types/api'
import { useToast } from '@/composables/useToast'
const props = defineProps<{
cfgId: number | null
region?: string
bucket: string
/** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
info?: Bucket
}>()
const emit = defineEmits<{ back: []; settings: [] }>()
const message = useToast()
/** hero 与 Tab:对象浏览 / 分享链接(PAR) */
const tab = ref<'objects' | 'par'>('objects')
/** 移动端 hero meta 默认收起,与租户/实例/VCN 详情一致 */
const isMobile = useIsMobile()
const heroExpanded = ref(false)
const PAGE_SIZE = 100
const prefix = ref('')
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
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)
// 作用域(租户/区域/桶)任一变化 = 数据集整体更换,走 resetScope 全量重置;
// 层内进目录/翻页只重查列表
watch([() => props.cfgId, () => props.region, () => props.bucket], () => resetScope())
watch([prefix, cursor], () => void result.run(), { immediate: true })
/** 作用域变化后重置路径、游标、选中项与弹窗再重查:旧列表配新作用域时,
* 删除/恢复/分享会打到新区域里同名桶的同名对象(2026-07 全量审查 #5) */
function resetScope() {
prefix.value = ''
filterInput.value = ''
cursorStack.value = []
cursor.value = ''
checkedKeys.value = []
showPar.value = false
showViewer.value = false
void result.run()
}
/** 有对象取回中(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() ?? ''
}
// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
interface TransferTask {
id: number
kind: 'up' | 'down'
name: string
percent: number
status: 'active' | 'done' | 'error'
/** 附注:如「已交由浏览器下载」(无进度) */
note?: string
error?: string
abort: () => void
retry: () => void
}
const transfers = ref<TransferTask[]>([])
const transfersCollapsed = ref(false)
let taskSeq = 0
const activeCount = computed(() => transfers.value.filter((t) => t.status === 'active').length)
const doneCount = computed(() => transfers.value.filter((t) => t.status === 'done').length)
function newTask(kind: 'up' | 'down', name: string, retry: () => void): TransferTask {
const task = reactive<TransferTask>({
id: ++taskSeq, kind, name, percent: 0, status: 'active', abort: () => {}, retry,
})
transfers.value.push(task)
transfersCollapsed.value = false
return task
}
function removeTask(task: TransferTask) {
task.abort()
transfers.value = transfers.value.filter((t) => t !== task)
}
function retryTask(task: TransferTask) {
transfers.value = transfers.value.filter((t) => t !== task)
task.retry()
}
function clearDone() {
transfers.value = transfers.value.filter((t) => t.status !== 'done')
}
// ---- 上传:PAR 直传,逐文件进度 ----
const fileInput = ref<HTMLInputElement | null>(null)
function pickFiles() {
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 })
}
/** objectName 进闭包:重试时不受当前浏览层级变化影响 */
function uploadOne(file: File, objectName = prefix.value + file.name) {
const task = newTask('up', file.name, () => uploadOne(file, objectName))
return doUpload(task, objectName, file)
}
async function doUpload(task: TransferTask, objectName: string, file: File) {
if (!props.cfgId) return
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
task.status = 'done'
task.percent = 100
} catch (err) {
task.status = 'error'
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()
}
}
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
/** 大小未知或超流式上限时交浏览器接管(新窗口导航) */
function willBrowserHandle(size: number): boolean {
return !size || size > DOWN_STREAM_LIMIT
}
function download(name: string, size?: number) {
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
// 预开窗口必须在点击手势内同步进行:PAR 签发后再 window.open 已脱离手势,
// 会被弹窗策略拦截(重试按钮点击同样是手势,拦截后可恢复)
const preWin = willBrowserHandle(known) ? window.open('', '_blank') : null
return doDownload(task, name, known, preWin)
}
async function doDownload(task: TransferTask, name: string, size: number, preWin: Window | null) {
if (!props.cfgId) {
closeIfBlank(preWin)
return
}
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: name,
accessType: 'ObjectRead',
expiresHours: 1,
})
const cleanable = await transferDown(task, par.fullUrl!, size, preWin)
task.status = 'done'
task.percent = 100
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
if (cleanable) void cleanupPar(par.id)
else parPanel.value?.refresh()
} catch (err) {
// 任何失败路径都未用到预开窗口(browserOpen 成功导航即不抛错),关掉空白页
closeIfBlank(preWin)
if ((err as DOMException)?.name === 'AbortError') return
task.status = 'error'
task.error = err instanceof Error ? err.message : '下载失败'
}
}
/** 关闭尚未导航的预开窗口(签发失败/取消时不留空白页) */
function closeIfBlank(preWin: Window | null) {
if (preWin && !preWin.closed) preWin.close()
}
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
async function transferDown(
task: TransferTask,
url: string,
size: number,
preWin: Window | null,
): Promise<boolean> {
if (willBrowserHandle(size)) return browserOpen(task, url, preWin)
try {
const blob = await fetchWithProgress(task, url, size)
saveBlob(blob, task.name)
return true
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') throw err
// 流式失败回退浏览器接管:此处已脱离手势无预开窗口,被拦会如实报错
return browserOpen(task, url, null)
}
}
/** 交浏览器接管:预开窗口就位则导航,否则现开;被拦时报错而非伪装成功 */
function browserOpen(task: TransferTask, url: string, preWin: Window | null): boolean {
const win = preWin && !preWin.closed ? preWin : window.open(url, '_blank')
if (!win) throw new Error('浏览器拦截了下载窗口:请允许本站弹窗,或点击重试')
if (win === preWin) win.location.replace(url)
task.note = '已交由浏览器下载'
return false
}
async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise<Blob> {
const ctrl = new AbortController()
task.abort = () => ctrl.abort()
const resp = await fetch(url, { signal: ctrl.signal })
if (!resp.ok || !resp.body) throw new Error(`下载失败(HTTP ${resp.status})`)
const total = Number(resp.headers.get('Content-Length')) || size
const reader = resp.body.getReader()
const chunks: BlobPart[] = []
let loaded = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
loaded += value.byteLength
task.percent = Math.min(99, Math.round((loaded / total) * 100))
}
return new Blob(chunks)
}
function saveBlob(blob: Blob, filename: string) {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = filename
a.click()
window.setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
}
// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
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 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 : '取回失败')
}
}
// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
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('')
const parIsBucket = computed(() => !parForm.objectName)
const PAR_MAX_EXPIRES_HOURS = 100 * 365 * 24
const parExpiresValid = computed(() => {
const hours = parForm.expiresHours
return hours !== null && hours >= 1 && hours <= PAR_MAX_EXPIRES_HOURS
})
/** 有效期快捷项;也可直接输入 1 至 876000 小时 */
const QUICK_HOURS = [
{ label: '1 小时', value: 1 },
{ label: '1 天', value: 24 },
{ label: '7 天', value: 168 },
{ label: '30 天', value: 720 },
{ label: '1 年', value: 8760 },
{ label: '10 年', value: 87600 },
]
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
function openParModal(objectName: string) {
Object.assign(parForm, {
objectName,
accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
expiresHours: 24,
})
parUrl.value = ''
showPar.value = true
}
async function doCreatePar() {
const expiresHours = parForm.expiresHours
if (!props.cfgId || expiresHours === null || !parExpiresValid.value) return
parBusy.value = true
try {
const par = await createPar(props.cfgId, props.bucket, {
region: props.region,
objectName: parForm.objectName,
accessType: parForm.accessType,
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, o.size) },
{ 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">
<!-- hero:与租户/VCN 详情同构——返回链 + 标题行(徽标/操作)+ meta 栅格(移动可折叠) -->
<div class="panel px-5 py-4">
<button
type="button"
class="mb-2 inline-flex cursor-pointer items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
@click="emit('back')"
>
返回存储桶列表
</button>
<div class="flex flex-wrap items-center gap-3">
<h1 class="page-title min-w-0 truncate">{{ bucket }}</h1>
<StatusBadge
v-if="info"
:kind="info.visibility === 'ObjectRead' ? 'prov' : 'check'"
:label="info.visibility === 'ObjectRead' ? '公共读' : '私有'"
:dot="false"
/>
<StatusBadge v-if="info?.versioningOn" kind="run" label="版本控制" :dot="false" />
<div class="flex-1" />
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
<div class="flex items-center gap-2 max-md:w-full">
<NButton size="small" @click="openParModal('')">分享桶</NButton>
<NButton size="small" @click="emit('settings')">桶设置</NButton>
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
</div>
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
</div>
<button
v-if="isMobile"
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
@click="heroExpanded = !heroExpanded"
>
{{ heroExpanded ? '收起详情' : '展开详情' }}
<svg
class="h-3 w-3 transition-transform"
:class="heroExpanded ? 'rotate-180' : ''"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<div
v-if="!isMobile || heroExpanded"
class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
>
<div class="min-w-0">
<div class="text-xs text-ink-3">对象数</div>
<div class="mono mt-0.5 text-[13px]">{{ info?.approximateCount ?? '…' }}</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">已用容量</div>
<div class="mono mt-0.5 text-[13px]">{{ info ? fmtBytes(info.approximateSize) : '…' }}</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">存储层</div>
<div class="mt-0.5 text-[13px]">{{ info?.storageTier ?? '…' }}</div>
</div>
<div class="min-w-0">
<div class="text-xs text-ink-3">创建时间</div>
<div class="mt-0.5 text-[13px]">{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}</div>
</div>
</div>
</div>
<NTabs v-model:value="tab" type="line">
<NTabPane name="objects" tab="对象" />
<NTabPane name="par" tab="分享链接" />
</NTabs>
<div v-show="tab === 'objects'" class="panel">
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3 max-md:px-3.5">
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
<span
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
:title="bucket"
@click="enterFolder('')"
>
{{ bucket }}
</span>
<template v-if="foldedOptions.length">
<span>/</span>
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink"></span>
</NDropdown>
</template>
<template v-for="c in visibleCrumbs" :key="c.prefix">
<span>/</span>
<span
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
:title="c.label"
@click="enterFolder(c.prefix)"
>
{{ c.label }}
</span>
</template>
</div>
<div class="flex items-center gap-2 max-md:w-full">
<NInput
v-model:value="filterInput"
size="small"
placeholder="搜索本层对象…"
clearable
class="!w-52 max-md:!w-auto max-md:flex-1"
/>
<NButton size="small" @click="showNewFolder = true">新建文件夹</NButton>
</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>
<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 gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
>
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
<span class="flex flex-none items-center gap-1.5">
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
下一页
</NButton>
</span>
</div>
</div>
<ParPanel v-show="tab === 'par'" ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
<!-- 传输列表浮层:上传主色 / 下载 info 色,失败红字可重试,可收起 -->
<Teleport to="body">
<div
v-if="transfers.length"
class="fixed right-5 bottom-5 z-40 w-80 max-w-[calc(100vw-24px)] max-md:right-3 max-md:bottom-[calc(64px_+_env(safe-area-inset-bottom))]"
>
<div class="overflow-hidden rounded-lg border border-line bg-card shadow-[0_10px_32px_-8px_rgba(0,0,0,0.22)]">
<div
class="flex items-center gap-2 bg-wash px-3.5 py-2 text-[12.5px]"
:class="transfersCollapsed ? '' : 'border-b border-line-soft'"
>
<span class="font-medium">传输列表</span>
<span v-if="activeCount" class="text-ink-3">{{ activeCount }} 进行中</span>
<div class="ml-auto flex items-center gap-1">
<button
v-if="doneCount"
type="button"
class="cursor-pointer rounded px-1.5 py-0.5 text-xs text-ink-3 hover:bg-wash-deep hover:text-ink"
@click="clearDone"
>
清除已完成
</button>
<button
type="button"
class="cursor-pointer rounded p-1 text-ink-3 hover:bg-wash-deep hover:text-ink"
:title="transfersCollapsed ? '展开' : '收起'"
@click="transfersCollapsed = !transfersCollapsed"
>
<svg
class="h-3.5 w-3.5 transition-transform"
:class="transfersCollapsed ? 'rotate-180' : ''"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</div>
</div>
<div v-show="!transfersCollapsed" class="max-h-60 overflow-y-auto">
<div
v-for="t in transfers"
:key="t.id"
class="flex items-center gap-2.5 border-b border-line-soft px-3.5 py-2 text-[12.5px] last:border-b-0"
>
<span
class="flex-none font-semibold"
:class="t.status === 'error' ? 'text-err' : t.kind === 'up' ? 'text-accent' : 'text-info'"
>
{{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
</span>
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-2">
<span class="mono truncate">{{ t.name }}</span>
<span v-if="t.status === 'active'" class="mono flex-none text-xs text-ink-3">
{{ t.percent }}%
</span>
<span v-else-if="t.status === 'done'" class="flex-none text-xs text-ok">完成</span>
</div>
<div v-if="t.status === 'active'" class="mt-1 h-1 overflow-hidden rounded-full bg-line-soft">
<span
class="block h-full rounded-full"
:class="t.kind === 'up' ? 'bg-accent' : 'bg-info'"
:style="{ width: `${t.percent}%` }"
/>
</div>
<div v-else-if="t.status === 'error'" class="mt-0.5 flex items-center gap-2 text-xs">
<span class="min-w-0 truncate text-err" :title="t.error">{{ t.error }}</span>
<span class="flex-none cursor-pointer font-medium text-accent" @click="retryTask(t)">
重试
</span>
</div>
<div v-else-if="t.note" class="mt-0.5 text-xs text-ink-3">{{ t.note }}</div>
</div>
<button
type="button"
class="flex-none cursor-pointer text-ink-3 hover:text-ink"
:title="t.status === 'active' ? '取消' : '移除'"
@click="removeTask(t)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</Teleport>
<ObjectViewerModal
v-model:show="showViewer"
:cfg-id="cfgId"
:region="region"
:bucket="bucket"
:object="viewerObject"
:versioning-on="info?.versioningOn"
:visibility="info?.visibility"
:namespace="info?.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="parIsBucket ? '分享桶' : '分享对象'"
:width="460"
:submitting="parBusy"
:submit-disabled="!!parUrl || !parExpiresValid"
submit-text="签发"
@update:show="showPar = $event"
@submit="doCreatePar"
>
<FormField :label="parIsBucket ? '范围' : '对象'">
<span class="mono text-[12.5px]">{{ parForm.objectName || '整桶对象(桶级链接)' }}</span>
</FormField>
<FormField label="权限">
<NRadioGroup v-model:value="parForm.accessType">
<NRadio :value="parIsBucket ? 'AnyObjectRead' : 'ObjectRead'">只读</NRadio>
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
</NRadioGroup>
</FormField>
<FormField label="有效期" hint="最长 100 876000 小时">
<div class="flex flex-wrap items-center gap-1.5">
<AppInputNumber
v-model:value="parForm.expiresHours"
:min="1"
:max="PAR_MAX_EXPIRES_HOURS"
: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>