@@ -4,9 +4,10 @@ import {
|
||||
NDataTable,
|
||||
NDropdown,
|
||||
NInput,
|
||||
NProgress,
|
||||
NRadio,
|
||||
NRadioGroup,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
type DataTableColumns,
|
||||
} from 'naive-ui'
|
||||
import { computed, h, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
@@ -26,25 +27,30 @@ import FormModal from '@/components/FormModal.vue'
|
||||
import FileIcon from '@/components/objectstorage/FileIcon.vue'
|
||||
import ObjectViewerModal from '@/components/objectstorage/ObjectViewerModal.vue'
|
||||
import ParPanel from '@/components/objectstorage/ParPanel.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { fmtBytes, fmtTime } from '@/composables/useFormat'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import type { ObjectSummary } from '@/types/api'
|
||||
import { useIsMobile } from '@/composables/useIsMobile'
|
||||
import type { Bucket, ObjectSummary } from '@/types/api'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
cfgId: number | null
|
||||
region?: string
|
||||
bucket: string
|
||||
/** 桶版本控制状态,编辑保存确认用;未知按未开启保守处理 */
|
||||
versioningOn?: boolean
|
||||
/** 桶可见性与 namespace,透传查看器做公网 URL 复制 */
|
||||
visibility?: string
|
||||
namespace?: string
|
||||
/** 桶列表信息:hero 徽标与统计、查看器公网 URL 等;直达链接列表未回时为空 */
|
||||
info?: Bucket
|
||||
}>()
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
const emit = defineEmits<{ back: []; settings: [] }>()
|
||||
|
||||
const message = useToast()
|
||||
|
||||
/** hero 与 Tab:对象浏览 / 分享链接(PAR) */
|
||||
const tab = ref<'objects' | 'par'>('objects')
|
||||
/** 移动端 hero meta 默认收起,与租户/实例/VCN 详情一致 */
|
||||
const isMobile = useIsMobile()
|
||||
const heroExpanded = ref(false)
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
const prefix = ref('')
|
||||
/** 本层搜索词:对当前已加载的行做客户端子串匹配,不发起服务端查询 */
|
||||
@@ -113,14 +119,50 @@ function prevPage() {
|
||||
cursor.value = cursorStack.value.pop() ?? ''
|
||||
}
|
||||
|
||||
// ---- 上传:PAR 直传,逐文件进度 ----
|
||||
interface UploadTask {
|
||||
// ---- 传输列表:上传 / 下载统一进右下浮层,失败可重试 ----
|
||||
interface TransferTask {
|
||||
id: number
|
||||
kind: 'up' | 'down'
|
||||
name: string
|
||||
percent: number
|
||||
status: 'active' | 'done' | 'error'
|
||||
/** 附注:如「已交由浏览器下载」(无进度) */
|
||||
note?: string
|
||||
error?: string
|
||||
abort: () => void
|
||||
retry: () => void
|
||||
}
|
||||
const uploads = ref<UploadTask[]>([])
|
||||
const transfers = ref<TransferTask[]>([])
|
||||
const transfersCollapsed = ref(false)
|
||||
let taskSeq = 0
|
||||
|
||||
const activeCount = computed(() => transfers.value.filter((t) => t.status === 'active').length)
|
||||
const doneCount = computed(() => transfers.value.filter((t) => t.status === 'done').length)
|
||||
|
||||
function newTask(kind: 'up' | 'down', name: string, retry: () => void): TransferTask {
|
||||
const task = reactive<TransferTask>({
|
||||
id: ++taskSeq, kind, name, percent: 0, status: 'active', abort: () => {}, retry,
|
||||
})
|
||||
transfers.value.push(task)
|
||||
transfersCollapsed.value = false
|
||||
return task
|
||||
}
|
||||
|
||||
function removeTask(task: TransferTask) {
|
||||
task.abort()
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
}
|
||||
|
||||
function retryTask(task: TransferTask) {
|
||||
transfers.value = transfers.value.filter((t) => t !== task)
|
||||
task.retry()
|
||||
}
|
||||
|
||||
function clearDone() {
|
||||
transfers.value = transfers.value.filter((t) => t.status !== 'done')
|
||||
}
|
||||
|
||||
// ---- 上传:PAR 直传,逐文件进度 ----
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function pickFiles() {
|
||||
@@ -134,11 +176,14 @@ async function onFilesPicked(e: Event) {
|
||||
void result.run({ silent: true })
|
||||
}
|
||||
|
||||
async function uploadOne(file: File) {
|
||||
/** objectName 进闭包:重试时不受当前浏览层级变化影响 */
|
||||
function uploadOne(file: File, objectName = prefix.value + file.name) {
|
||||
const task = newTask('up', file.name, () => uploadOne(file, objectName))
|
||||
return doUpload(task, objectName, file)
|
||||
}
|
||||
|
||||
async function doUpload(task: TransferTask, objectName: string, file: File) {
|
||||
if (!props.cfgId) return
|
||||
const objectName = prefix.value + file.name
|
||||
const task = reactive<UploadTask>({ name: file.name, percent: 0, abort: () => {} })
|
||||
uploads.value.push(task)
|
||||
let parId = ''
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
@@ -151,9 +196,10 @@ async function uploadOne(file: File) {
|
||||
const { promise, abort } = uploadViaPar(par.fullUrl!, file, (p) => (task.percent = p))
|
||||
task.abort = abort
|
||||
await promise
|
||||
uploads.value = uploads.value.filter((t) => t !== task)
|
||||
message.success(`${file.name} 上传完成`)
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
} catch (err) {
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '上传失败'
|
||||
} finally {
|
||||
if (parId) void cleanupPar(parId)
|
||||
@@ -171,9 +217,81 @@ async function cleanupPar(parId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function dismissUpload(task: UploadTask) {
|
||||
task.abort()
|
||||
uploads.value = uploads.value.filter((t) => t !== task)
|
||||
// ---- 下载:小文件 fetch 流式读进度,超限 / 跨域受阻交给浏览器直接下载 ----
|
||||
const DOWN_STREAM_LIMIT = 256 * 1024 * 1024
|
||||
|
||||
function download(name: string, size?: number) {
|
||||
const known = size ?? result.data.value?.objects.find((o) => o.name === name)?.size ?? 0
|
||||
const task = newTask('down', name.split('/').pop() || name, () => download(name, known))
|
||||
return doDownload(task, name, known)
|
||||
}
|
||||
|
||||
async function doDownload(task: TransferTask, name: string, size: number) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 1,
|
||||
})
|
||||
const cleanable = await transferDown(task, par.fullUrl!, size)
|
||||
task.status = 'done'
|
||||
task.percent = 100
|
||||
// 浏览器接管的下载须保持链接存活(1 小时自然过期),不可即刻清理
|
||||
if (cleanable) void cleanupPar(par.id)
|
||||
else parPanel.value?.refresh()
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') return
|
||||
task.status = 'error'
|
||||
task.error = err instanceof Error ? err.message : '下载失败'
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回 true 表示内容已就地保存、PAR 可即刻清理;取消(AbortError)原样上抛 */
|
||||
async function transferDown(task: TransferTask, url: string, size: number): Promise<boolean> {
|
||||
if (!size || size > DOWN_STREAM_LIMIT) return browserOpen(task, url)
|
||||
try {
|
||||
const blob = await fetchWithProgress(task, url, size)
|
||||
saveBlob(blob, task.name)
|
||||
return true
|
||||
} catch (err) {
|
||||
if ((err as DOMException)?.name === 'AbortError') throw err
|
||||
return browserOpen(task, url)
|
||||
}
|
||||
}
|
||||
|
||||
function browserOpen(task: TransferTask, url: string): boolean {
|
||||
window.open(url, '_blank')
|
||||
task.note = '已交由浏览器下载'
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchWithProgress(task: TransferTask, url: string, size: number): Promise<Blob> {
|
||||
const ctrl = new AbortController()
|
||||
task.abort = () => ctrl.abort()
|
||||
const resp = await fetch(url, { signal: ctrl.signal })
|
||||
if (!resp.ok || !resp.body) throw new Error(`下载失败(HTTP ${resp.status})`)
|
||||
const total = Number(resp.headers.get('Content-Length')) || size
|
||||
const reader = resp.body.getReader()
|
||||
const chunks: BlobPart[] = []
|
||||
let loaded = 0
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
loaded += value.byteLength
|
||||
task.percent = Math.min(99, Math.round((loaded / total) * 100))
|
||||
}
|
||||
return new Blob(chunks)
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = filename
|
||||
a.click()
|
||||
window.setTimeout(() => URL.revokeObjectURL(a.href), 10_000)
|
||||
}
|
||||
|
||||
// ---- 新建文件夹:上传 0 字节的「name/」对象 ----
|
||||
@@ -208,22 +326,6 @@ async function doCreateFolder() {
|
||||
}
|
||||
|
||||
// ---- 行操作 ----
|
||||
async function download(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
const par = await createPar(props.cfgId, props.bucket, {
|
||||
region: props.region,
|
||||
objectName: name,
|
||||
accessType: 'ObjectRead',
|
||||
expiresHours: 1,
|
||||
})
|
||||
parPanel.value?.refresh()
|
||||
window.open(par.fullUrl, '_blank')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '生成下载链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function removeObject(name: string) {
|
||||
if (!props.cfgId) return
|
||||
try {
|
||||
@@ -248,7 +350,7 @@ async function restore(name: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 临时链接签发 ----
|
||||
// ---- 临时链接签发:对象级 ObjectRead*,桶级 AnyObjectRead*(objectName 为空) ----
|
||||
const showPar = ref(false)
|
||||
const parBusy = ref(false)
|
||||
const parForm = reactive<{ objectName: string; accessType: string; expiresHours: number | null }>({
|
||||
@@ -257,6 +359,7 @@ const parForm = reactive<{ objectName: string; accessType: string; expiresHours:
|
||||
expiresHours: 24,
|
||||
})
|
||||
const parUrl = ref('')
|
||||
const parIsBucket = computed(() => !parForm.objectName)
|
||||
/** 有效期快捷项;也可直接输入 1-720 任意小时数 */
|
||||
const QUICK_HOURS = [
|
||||
{ label: '1 小时', value: 1 },
|
||||
@@ -267,7 +370,11 @@ const QUICK_HOURS = [
|
||||
const parPanel = ref<InstanceType<typeof ParPanel> | null>(null)
|
||||
|
||||
function openParModal(objectName: string) {
|
||||
Object.assign(parForm, { objectName, accessType: 'ObjectRead', expiresHours: 24 })
|
||||
Object.assign(parForm, {
|
||||
objectName,
|
||||
accessType: objectName ? 'ObjectRead' : 'AnyObjectRead',
|
||||
expiresHours: 24,
|
||||
})
|
||||
parUrl.value = ''
|
||||
showPar.value = true
|
||||
}
|
||||
@@ -437,7 +544,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
)
|
||||
: h(
|
||||
NButton,
|
||||
{ size: 'tiny', quaternary: true, onClick: () => download(o.name) },
|
||||
{ size: 'tiny', quaternary: true, onClick: () => download(o.name, o.size) },
|
||||
{ default: () => '下载' },
|
||||
),
|
||||
h(
|
||||
@@ -465,51 +572,111 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5">
|
||||
<!-- hero:与租户/VCN 详情同构——返回链 + 标题行(徽标/操作)+ meta 栅格(移动可折叠) -->
|
||||
<div class="panel px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
class="mb-2 inline-flex cursor-pointer items-center gap-1.5 text-[13px] text-ink-3 hover:text-ink"
|
||||
@click="emit('back')"
|
||||
>
|
||||
← 返回存储桶列表
|
||||
</button>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h1 class="page-title min-w-0 truncate">{{ bucket }}</h1>
|
||||
<StatusBadge
|
||||
v-if="info"
|
||||
:kind="info.visibility === 'ObjectRead' ? 'prov' : 'check'"
|
||||
:label="info.visibility === 'ObjectRead' ? '公共读' : '私有'"
|
||||
:dot="false"
|
||||
/>
|
||||
<StatusBadge v-if="info?.versioningOn" kind="run" label="版本控制" :dot="false" />
|
||||
<div class="flex-1" />
|
||||
<!-- 操作按钮组:移动端整组独立成行,不随标题换行散落 -->
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NButton size="small" @click="openParModal('')">分享桶</NButton>
|
||||
<NButton size="small" @click="emit('settings')">桶设置</NButton>
|
||||
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="mt-2 flex cursor-pointer items-center gap-1 text-xs text-ink-3"
|
||||
@click="heroExpanded = !heroExpanded"
|
||||
>
|
||||
{{ heroExpanded ? '收起详情' : '展开详情' }}
|
||||
<svg
|
||||
class="h-3 w-3 transition-transform"
|
||||
:class="heroExpanded ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
v-if="!isMobile || heroExpanded"
|
||||
class="mt-3 grid grid-cols-4 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
|
||||
<span class="cursor-pointer hover:text-ink" @click="emit('back')">存储桶</span>
|
||||
<div class="text-xs text-ink-3">对象数</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info?.approximateCount ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">已用容量</div>
|
||||
<div class="mono mt-0.5 text-[13px]">{{ info ? fmtBytes(info.approximateSize) : '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">存储层</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.storageTier ?? '…' }}</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs text-ink-3">创建时间</div>
|
||||
<div class="mt-0.5 text-[13px]">{{ info?.timeCreated ? fmtTime(info.timeCreated) : '…' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NTabs v-model:value="tab" type="line">
|
||||
<NTabPane name="objects" tab="对象" />
|
||||
<NTabPane name="par" tab="分享链接" />
|
||||
</NTabs>
|
||||
|
||||
<div v-show="tab === 'objects'" class="panel">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3 max-md:px-3.5">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-[13px] text-ink-3">
|
||||
<span
|
||||
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
|
||||
:title="bucket"
|
||||
@click="enterFolder('')"
|
||||
>
|
||||
{{ bucket }}
|
||||
</span>
|
||||
<template v-if="foldedOptions.length">
|
||||
<span>/</span>
|
||||
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
|
||||
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
|
||||
</NDropdown>
|
||||
</template>
|
||||
<template v-for="c in visibleCrumbs" :key="c.prefix">
|
||||
<span>/</span>
|
||||
<span
|
||||
class="mono inline-block max-w-48 cursor-pointer truncate align-bottom font-medium text-ink hover:text-accent"
|
||||
:title="bucket"
|
||||
@click="enterFolder('')"
|
||||
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
|
||||
:title="c.label"
|
||||
@click="enterFolder(c.prefix)"
|
||||
>
|
||||
{{ bucket }}
|
||||
{{ c.label }}
|
||||
</span>
|
||||
<template v-if="foldedOptions.length">
|
||||
<span>/</span>
|
||||
<NDropdown trigger="click" :options="foldedOptions" @select="(k: string) => enterFolder(k)">
|
||||
<span class="cursor-pointer rounded px-1 hover:bg-wash hover:text-ink">…</span>
|
||||
</NDropdown>
|
||||
</template>
|
||||
<template v-for="c in visibleCrumbs" :key="c.prefix">
|
||||
<span>/</span>
|
||||
<span
|
||||
class="mono inline-block max-w-40 cursor-pointer truncate align-bottom hover:text-ink"
|
||||
:title="c.label"
|
||||
@click="enterFolder(c.prefix)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
前缀模式展示,文件夹为虚拟层级 · 点击对象名预览 / 查看详情 · 上传经 PAR 直传不经面板中转
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex items-center gap-2 max-md:w-full">
|
||||
<NInput
|
||||
v-model:value="filterInput"
|
||||
size="small"
|
||||
placeholder="搜索本层对象…"
|
||||
clearable
|
||||
class="!w-52"
|
||||
class="!w-52 max-md:!w-auto max-md:flex-1"
|
||||
/>
|
||||
<NButton size="small" @click="showNewFolder = true">新建文件夹</NButton>
|
||||
<NButton size="small" type="primary" @click="pickFiles">上传对象</NButton>
|
||||
<input ref="fileInput" type="file" multiple class="hidden" @change="onFilesPicked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,23 +701,6 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="uploads.length" class="border-b border-line-soft px-4.5 py-2">
|
||||
<div v-for="t in uploads" :key="t.name" class="flex items-center gap-3 py-1 text-[12.5px]">
|
||||
<span class="mono max-w-60 truncate">{{ t.name }}</span>
|
||||
<NProgress
|
||||
v-if="!t.error"
|
||||
type="line"
|
||||
:percentage="t.percent"
|
||||
:height="4"
|
||||
:show-indicator="false"
|
||||
class="max-w-40"
|
||||
/>
|
||||
<span v-if="t.error" class="text-err">{{ t.error }}</span>
|
||||
<span v-else class="mono text-ink-3">{{ t.percent }}%</span>
|
||||
<span class="cursor-pointer text-ink-3 hover:text-ink" @click="dismissUpload(t)">取消</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedKeys"
|
||||
:columns="columns"
|
||||
@@ -560,10 +710,10 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
:row-key="(r: Row) => r.key"
|
||||
/>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
class="flex items-center justify-between gap-3 border-t border-line-soft px-4.5 py-2.5 text-[12.5px] text-ink-3"
|
||||
>
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · Archive 层需恢复后才可下载</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span>每页 {{ PAGE_SIZE }} 条 · 文件夹为虚拟层级 · Archive 层需恢复后下载</span>
|
||||
<span class="flex flex-none items-center gap-1.5">
|
||||
<NButton size="tiny" :disabled="!cursorStack.length" @click="prevPage">上一页</NButton>
|
||||
<NButton size="tiny" :disabled="!result.data.value?.nextStartWith" @click="nextPage">
|
||||
下一页
|
||||
@@ -572,7 +722,97 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ParPanel ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
|
||||
<ParPanel v-show="tab === 'par'" ref="parPanel" :cfg-id="cfgId" :region="region" :bucket="bucket" />
|
||||
|
||||
<!-- 传输列表浮层:上传主色 / 下载 info 色,失败红字可重试,可收起 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="transfers.length"
|
||||
class="fixed right-5 bottom-5 z-40 w-80 max-w-[calc(100vw-24px)] max-md:right-3 max-md:bottom-[calc(64px_+_env(safe-area-inset-bottom))]"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg border border-line bg-card shadow-[0_10px_32px_-8px_rgba(0,0,0,0.22)]">
|
||||
<div
|
||||
class="flex items-center gap-2 bg-wash px-3.5 py-2 text-[12.5px]"
|
||||
:class="transfersCollapsed ? '' : 'border-b border-line-soft'"
|
||||
>
|
||||
<span class="font-medium">传输列表</span>
|
||||
<span v-if="activeCount" class="text-ink-3">{{ activeCount }} 进行中</span>
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
v-if="doneCount"
|
||||
type="button"
|
||||
class="cursor-pointer rounded px-1.5 py-0.5 text-xs text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
@click="clearDone"
|
||||
>
|
||||
清除已完成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded p-1 text-ink-3 hover:bg-wash-deep hover:text-ink"
|
||||
:title="transfersCollapsed ? '展开' : '收起'"
|
||||
@click="transfersCollapsed = !transfersCollapsed"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 transition-transform"
|
||||
:class="transfersCollapsed ? 'rotate-180' : ''"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||||
stroke-linecap="round" stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!transfersCollapsed" class="max-h-60 overflow-y-auto">
|
||||
<div
|
||||
v-for="t in transfers"
|
||||
:key="t.id"
|
||||
class="flex items-center gap-2.5 border-b border-line-soft px-3.5 py-2 text-[12.5px] last:border-b-0"
|
||||
>
|
||||
<span
|
||||
class="flex-none font-semibold"
|
||||
:class="t.status === 'error' ? 'text-err' : t.kind === 'up' ? 'text-accent' : 'text-info'"
|
||||
>
|
||||
{{ t.status === 'error' ? '✕' : t.kind === 'up' ? '↑' : '↓' }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="mono truncate">{{ t.name }}</span>
|
||||
<span v-if="t.status === 'active'" class="mono flex-none text-xs text-ink-3">
|
||||
{{ t.percent }}%
|
||||
</span>
|
||||
<span v-else-if="t.status === 'done'" class="flex-none text-xs text-ok">完成</span>
|
||||
</div>
|
||||
<div v-if="t.status === 'active'" class="mt-1 h-1 overflow-hidden rounded-full bg-line-soft">
|
||||
<span
|
||||
class="block h-full rounded-full"
|
||||
:class="t.kind === 'up' ? 'bg-accent' : 'bg-info'"
|
||||
:style="{ width: `${t.percent}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="t.status === 'error'" class="mt-0.5 flex items-center gap-2 text-xs">
|
||||
<span class="min-w-0 truncate text-err" :title="t.error">{{ t.error }}</span>
|
||||
<span class="flex-none cursor-pointer font-medium text-accent" @click="retryTask(t)">
|
||||
重试
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="t.note" class="mt-0.5 text-xs text-ink-3">{{ t.note }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none cursor-pointer text-ink-3 hover:text-ink"
|
||||
:title="t.status === 'active' ? '取消' : '移除'"
|
||||
@click="removeTask(t)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<ObjectViewerModal
|
||||
v-model:show="showViewer"
|
||||
@@ -580,9 +820,9 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
:region="region"
|
||||
:bucket="bucket"
|
||||
:object="viewerObject"
|
||||
:versioning-on="versioningOn"
|
||||
:visibility="visibility"
|
||||
:namespace="namespace"
|
||||
:versioning-on="info?.versioningOn"
|
||||
:visibility="info?.visibility"
|
||||
:namespace="info?.namespace"
|
||||
@download="download"
|
||||
@share="openParModal"
|
||||
@changed="() => void result.run({ silent: true })"
|
||||
@@ -605,7 +845,7 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
|
||||
<FormModal
|
||||
:show="showPar"
|
||||
title="分享对象"
|
||||
:title="parIsBucket ? '分享桶' : '分享对象'"
|
||||
:width="460"
|
||||
:submitting="parBusy"
|
||||
:submit-disabled="!!parUrl || !parForm.expiresHours"
|
||||
@@ -613,13 +853,13 @@ const columns = computed<DataTableColumns<Row>>(() => [
|
||||
@update:show="showPar = $event"
|
||||
@submit="doCreatePar"
|
||||
>
|
||||
<FormField label="对象">
|
||||
<span class="mono text-[12.5px]">{{ parForm.objectName || '(桶级)' }}</span>
|
||||
<FormField :label="parIsBucket ? '范围' : '对象'">
|
||||
<span class="mono text-[12.5px]">{{ parForm.objectName || '整桶对象(桶级链接)' }}</span>
|
||||
</FormField>
|
||||
<FormField label="权限">
|
||||
<NRadioGroup v-model:value="parForm.accessType">
|
||||
<NRadio value="ObjectRead">只读</NRadio>
|
||||
<NRadio value="ObjectReadWrite">读写</NRadio>
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectRead' : 'ObjectRead'">只读</NRadio>
|
||||
<NRadio :value="parIsBucket ? 'AnyObjectReadWrite' : 'ObjectReadWrite'">读写</NRadio>
|
||||
</NRadioGroup>
|
||||
</FormField>
|
||||
<FormField label="有效期" hint="OCI 上限 30 天(720 小时)">
|
||||
|
||||
Reference in New Issue
Block a user