950 lines
33 KiB
Vue
950 lines
33 KiB
Vue
<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>
|