Files
oci-portal-dash/src/components/objectstorage/ObjectViewerModal.vue
T
2026-07-21 19:26:22 +08:00

528 lines
17 KiB
Vue

<script setup lang="ts">
import { NButton, NInput, NModal, NSpin } from 'naive-ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import {
deleteObject,
getObjectContent,
getObjectDetail,
putObjectContent,
renameObject,
} from '@/api/objectstorage'
import { ApiError } from '@/api/request'
import ConfirmPop from '@/components/ConfirmPop.vue'
import { extOf, previewKindOf } from '@/components/objectstorage/codeHighlight'
import { canFormat, formatSource } from '@/components/objectstorage/format'
import FileIcon from '@/components/objectstorage/FileIcon.vue'
import OfficePreview from '@/components/objectstorage/viewer/OfficePreview.vue'
import TextEditPane from '@/components/objectstorage/viewer/TextEditPane.vue'
import TextPreviewPane from '@/components/objectstorage/viewer/TextPreviewPane.vue'
import { fmtBytes, fmtTime } from '@/composables/useFormat'
import { useAsync } from '@/composables/useAsync'
import { useIsMobile } from '@/composables/useIsMobile'
import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
/** 对象查看器:预览与编辑合一,左内容区 + 右信息栏。
* 内容经面板中转直读直写(不签发 PAR);保存以 If-Match 做并发保护,
* 412 冲突与无版本控制桶走二段确认。 */
const props = defineProps<{
show: boolean
cfgId: number | null
region?: string
bucket: string
object: string
/** 桶版本控制状态;未知按未开启保守处理 */
versioningOn?: boolean
/** 桶可见性(NoPublicAccess / ObjectRead);公共读时「复制 URI」变「复制 URL」 */
visibility?: string
/** 桶 namespace,拼公网对象 URL 用 */
namespace?: string
}>()
const emit = defineEmits<{
'update:show': [boolean]
download: [string]
share: [string]
/** 保存/重命名/删除后,列表需要刷新 */
changed: []
}>()
const message = useToast()
/** 移动端全屏(复用 main.css 的 .form-modal-mobile 全局全屏规则) */
const isMobile = useIsMobile()
const modalStyle = computed(() =>
isMobile.value
? { width: '100vw', height: '100dvh', maxWidth: '100vw', margin: '0', borderRadius: '0' }
: { width: '1080px', maxWidth: 'calc(100vw - 24px)' },
)
/** 文本预览/编辑体积上限;更大的文本请下载查看 */
const TEXT_MAX = 1024 * 1024
/** 图片与 office/pdf 的中转上限(与后端 20MB 限制一致) */
const BINARY_MAX = 20 * 1024 * 1024
/** pre 渲染字符上限,超出截断展示 */
const TEXT_SHOW_MAX = 256 * 1024
const detail = useAsync(async () => {
if (!props.cfgId || !props.object) return null
return getObjectDetail(props.cfgId, props.bucket, props.object, props.region)
}, false)
const ext = computed(() => extOf(props.object))
const kind = computed(() => previewKindOf(props.object))
const archived = computed(() => {
const d = detail.data.value
return d?.storageTier === 'Archive' && d.archivalState !== 'Restored'
})
const size = computed(() => detail.data.value?.size ?? 0)
const tooBig = computed(() =>
kind.value === 'text' ? size.value > TEXT_MAX : size.value > BINARY_MAX,
)
const canEdit = computed(() => kind.value === 'text' && !archived.value && !tooBig.value)
const officeKind = computed(() => {
const k = kind.value
return k === 'docx' || k === 'xlsx' || k === 'pdf' || k === 'pptx' ? k : null
})
const blockedReason = computed(() => {
if (kind.value === 'none') return '该格式暂不支持预览,请下载查看'
if (archived.value) return 'Archive 对象需先恢复才能预览'
if (tooBig.value)
return kind.value === 'text' ? '文本超过 1 MB,请下载查看' : '文件超过 20 MB,请下载查看'
return ''
})
// ---- 内容状态 ----
const mode = ref<'preview' | 'edit'>('preview')
const loading = ref(false)
const loadError = ref('')
/** 文本完整内容(编辑基线,不做美化改写) */
const fullText = ref('')
/** 展示文本(json 美化、超长截断) */
const previewText = ref('')
const truncated = ref(false)
const imageUrl = ref('')
const officeData = ref<ArrayBuffer | null>(null)
const officeError = ref('')
const contentEtag = ref('')
const contentType = ref('')
watch(
() => [props.show, props.object],
([show]) => {
if (!show) {
revokeImage()
return
}
reset()
void detail.run().then(() => {
if (!blockedReason.value) void loadContent()
})
},
)
onBeforeUnmount(revokeImage)
function reset() {
mode.value = 'preview'
// 立即清掉上一对象的元数据,右栏转入加载态,避免残留展示
detail.data.value = null
loading.value = false
loadError.value = ''
fullText.value = ''
previewText.value = ''
truncated.value = false
revokeImage()
officeData.value = null
officeError.value = ''
contentEtag.value = ''
contentType.value = ''
renaming.value = false
resetEdit()
}
function revokeImage() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = ''
}
async function loadContent() {
if (!props.cfgId || loading.value) return
loading.value = true
loadError.value = ''
try {
const c = await getObjectContent(props.cfgId, props.bucket, props.object, props.region)
contentEtag.value = c.etag
contentType.value = c.contentType
await applyContent(c.blob)
} catch (e) {
loadError.value = e instanceof Error ? e.message : '内容加载失败'
} finally {
loading.value = false
}
}
async function applyContent(blob: Blob) {
if (kind.value === 'image') {
imageUrl.value = URL.createObjectURL(blob)
return
}
if (kind.value === 'text') {
setText(await blob.text())
return
}
officeData.value = await blob.arrayBuffer()
}
function setText(raw: string) {
fullText.value = raw
const pretty = ext.value === 'json' ? prettyJson(raw) : raw
truncated.value = pretty.length > TEXT_SHOW_MAX
previewText.value = truncated.value ? pretty.slice(0, TEXT_SHOW_MAX) : pretty
}
/** JSON 美化失败时原样展示 */
function prettyJson(s: string): string {
try {
return JSON.stringify(JSON.parse(s), null, 2)
} catch {
return s
}
}
// ---- 编辑 ----
const editText = ref('')
const saving = ref(false)
/** 非空时保存进入二段确认态,内容为需用户确认的原因 */
const pendingConfirm = ref('')
/** 412 冲突确认后置真:下次保存不带 If-Match,无条件覆盖 */
const overwrite = ref(false)
const editByteSize = computed(() => new Blob([editText.value]).size)
function resetEdit() {
editText.value = ''
saving.value = false
pendingConfirm.value = ''
overwrite.value = false
}
function startEdit() {
editText.value = fullText.value
pendingConfirm.value = ''
overwrite.value = false
mode.value = 'edit'
}
function cancelEdit() {
mode.value = 'preview'
resetEdit()
}
const canBeautify = computed(() => canFormat(ext.value))
/** 一键美化:json 原生,其余 prettier standalone(动态加载);语法错误只提示不改动 */
async function beautify() {
try {
const out = await formatSource(editText.value, ext.value)
if (out === null) return
if (out === editText.value) {
message.success('已是规范格式')
return
}
editText.value = out
message.success('已格式化')
} catch {
message.error('格式化失败:内容存在语法错误')
}
}
async function save() {
if (!props.cfgId || saving.value) return
if (!pendingConfirm.value && !props.versioningOn) {
pendingConfirm.value = '该桶未开启版本控制,保存将直接覆盖原内容,不可恢复。'
return
}
saving.value = true
try {
const { etag } = await putObjectContent(
props.cfgId,
props.bucket,
props.object,
editText.value,
contentType.value || 'text/plain',
overwrite.value ? undefined : contentEtag.value || undefined,
props.region,
)
applySaved(etag)
} catch (e) {
onSaveError(e)
} finally {
saving.value = false
}
}
function applySaved(etag: string) {
contentEtag.value = etag
setText(editText.value)
message.success('已保存')
mode.value = 'preview'
resetEdit()
emit('changed')
void detail.run({ silent: true })
}
function onSaveError(e: unknown) {
if (e instanceof ApiError && e.status === 412) {
overwrite.value = true
pendingConfirm.value = '对象在编辑期间已被修改(ETag 变化),继续保存将覆盖他人的更改。'
return
}
message.error(e instanceof Error ? e.message : '保存失败')
}
// ---- 重命名 / 删除 / 复制 ----
const renaming = ref(false)
const newName = ref('')
const renameBusy = ref(false)
function startRename() {
newName.value = props.object
renaming.value = true
}
async function doRename() {
if (!props.cfgId || !newName.value.trim() || newName.value === props.object) return
renameBusy.value = true
try {
await renameObject(props.cfgId, props.bucket, props.object, newName.value.trim(), props.region)
message.success('已重命名')
emit('update:show', false)
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '重命名失败')
} finally {
renameBusy.value = false
}
}
async function doDelete() {
if (!props.cfgId) return
try {
await deleteObject(props.cfgId, props.bucket, props.object, props.region)
message.success('对象已删除')
emit('update:show', false)
emit('changed')
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
/** Archive 层(任意恢复态):PAR 对其无效,分享一律禁用 */
const isArchiveTier = computed(() => detail.data.value?.storageTier === 'Archive')
/** 公共读桶:对象有公网直链,「复制 URI」升级为「复制 URL」 */
const isPublicBucket = computed(() => props.visibility === 'ObjectRead')
const scope = useScopeStore()
/** 公共读桶对象的公网直链;对象名整体转义(/ → %2F,OCI 控制台同款) */
function publicUrl(): string {
const region = props.region || scope.region
return `https://objectstorage.${region}.oraclecloud.com/n/${props.namespace}/b/${props.bucket}/o/${encodeURIComponent(props.object)}`
}
async function copyUri() {
const pub = isPublicBucket.value
await navigator.clipboard.writeText(pub ? publicUrl() : `oci://${props.bucket}/${props.object}`)
message.success(pub ? 'URL 已复制' : 'URI 已复制')
}
const kvRows = computed(() => {
const d = detail.data.value
if (!d) return []
return [
{ k: '大小', v: `${fmtBytes(d.size)}(${d.size.toLocaleString()} B)` },
{ k: 'Content-Type', v: d.contentType || '—', mono: true },
{ k: 'ETag', v: d.etag || '—', mono: true },
{ k: 'MD5', v: d.contentMd5 || '—', mono: true },
{ k: '存储层', v: d.storageTier || 'Standard' },
{ k: '版本控制', v: props.versioningOn ? '已开启' : '未开启' },
{ k: '修改时间', v: fmtTime(d.timeModified) },
]
})
</script>
<template>
<NModal
:show="show"
preset="card"
:mask-closable="mode !== 'edit'"
:style="modalStyle"
:class="isMobile ? 'form-modal-mobile' : ''"
@update:show="emit('update:show', $event)"
>
<template #header>
<span class="mono flex min-w-0 items-center gap-2 text-[13.5px]">
<FileIcon :name="object" :size="18" />
<span class="truncate" :title="object">{{ object }}</span>
<span
v-if="mode === 'edit'"
class="flex-none rounded bg-wash px-1.5 py-px text-[10.5px] font-medium text-ink-3"
>
编辑
</span>
</span>
</template>
<div class="flex min-h-0 flex-col gap-4 md:flex-row max-md:flex-1 max-md:overflow-y-auto">
<!-- :内容区(移动端全屏形态下压缩预览高度,信息与操作随外层滚动) -->
<div
class="flex h-[65vh] min-w-0 flex-1 flex-col rounded-lg border border-line-soft bg-wash/50 p-3 max-md:h-auto max-md:min-h-[45dvh] max-md:flex-none"
>
<div
v-if="detail.loading.value || loading"
class="flex flex-1 items-center justify-center"
>
<NSpin size="small" />
</div>
<div
v-else-if="blockedReason"
class="flex flex-1 items-center justify-center text-xs text-ink-3"
>
{{ blockedReason }}
</div>
<div v-else-if="loadError" class="flex flex-1 flex-col items-center justify-center gap-2">
<div class="text-xs text-err">{{ loadError }}</div>
<NButton size="small" @click="loadContent">重试</NButton>
</div>
<template v-else-if="mode === 'edit'">
<div
class="relative min-h-0 flex-1 overflow-hidden rounded-lg border border-line bg-white transition-colors focus-within:border-accent"
>
<TextEditPane v-model="editText" :object-name="object" :disabled="saving" />
</div>
<div class="mt-1.5 flex-none text-xs text-ink-3">
UTF-8 文本 · {{ fmtBytes(editByteSize) }} · 保存经面板中转写回,If-Match 防并发覆盖
</div>
</template>
<img
v-else-if="imageUrl"
:src="imageUrl"
:alt="object"
class="mx-auto max-h-full min-h-0 rounded object-contain"
@error="loadError = '图片加载失败,请下载查看'; revokeImage()"
/>
<TextPreviewPane
v-else-if="kind === 'text' && (previewText || truncated || !loading)"
:text="previewText"
:ext="ext"
:truncated="truncated"
/>
<template v-else-if="officeData && officeKind">
<div
v-if="officeError"
class="flex flex-1 items-center justify-center text-xs text-err"
>
{{ officeError }}
</div>
<div v-else class="min-h-0 flex-1 overflow-auto rounded bg-white">
<OfficePreview :kind="officeKind" :data="officeData" @error="officeError = $event" />
</div>
<div v-if="officeKind === 'pptx' && !officeError" class="mt-1.5 flex-none text-xs text-ink-3">
pptx 预览为实验能力,复杂版式可能失真
</div>
</template>
</div>
<!-- :信息与操作 -->
<div class="flex w-full flex-none flex-col gap-3 md:w-72">
<div v-if="detail.loading.value" class="flex justify-center py-10">
<NSpin size="small" />
</div>
<template v-else>
<div class="flex flex-col gap-0.5">
<div
v-for="row in kvRows"
:key="row.k"
class="flex justify-between gap-3 border-b border-line-soft py-1.5 text-[12px]"
>
<span class="flex-none text-ink-3">{{ row.k }}</span>
<span class="min-w-0 text-right break-all" :class="row.mono ? 'mono' : ''">
{{ row.v }}
</span>
</div>
</div>
<template v-if="mode === 'edit'">
<div
v-if="pendingConfirm"
class="rounded-lg bg-warn/12 px-3 py-2 text-xs leading-relaxed text-warn"
>
{{ pendingConfirm }}
</div>
<NButton v-if="canBeautify" size="small" :disabled="saving" @click="beautify">
美化格式
</NButton>
<div class="flex gap-2">
<NButton
size="small"
type="primary"
class="flex-1"
:loading="saving"
@click="save"
>
{{ pendingConfirm ? '确认覆盖并保存' : '保存' }}
</NButton>
<NButton size="small" :disabled="saving" @click="cancelEdit">取消</NButton>
</div>
</template>
<div v-else-if="renaming" class="flex flex-col gap-2">
<NInput v-model:value="newName" size="small" class="mono" />
<div class="flex gap-2">
<NButton size="small" type="primary" :loading="renameBusy" @click="doRename">
确定
</NButton>
<NButton size="small" :disabled="renameBusy" @click="renaming = false">取消</NButton>
</div>
</div>
<template v-else>
<div :title="archived ? 'Archive 对象需先恢复才能下载' : ''">
<NButton
size="small"
type="primary"
block
:disabled="archived"
@click="emit('download', object)"
>
下载
</NButton>
</div>
<div class="grid grid-cols-2 gap-1.5">
<NButton v-if="canEdit" size="small" @click="startEdit">编辑</NButton>
<div :title="isArchiveTier ? 'Archive 层对象不支持预签名分享' : ''">
<NButton size="small" block :disabled="isArchiveTier" @click="emit('share', object)">
分享
</NButton>
</div>
<NButton size="small" @click="copyUri">
{{ isPublicBucket ? '复制 URL' : '复制 URI' }}
</NButton>
<NButton size="small" @click="startRename">重命名</NButton>
</div>
<ConfirmPop
title="删除该对象?"
content="未开版本控制时不可恢复。"
confirm-text="删除"
:on-confirm="doDelete"
>
<template #trigger>
<NButton size="small" type="error" ghost block>删除</NButton>
</template>
</ConfirmPop>
</template>
</template>
</div>
</div>
</NModal>
</template>