@@ -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>
|
||||
Reference in New Issue
Block a user