528 lines
17 KiB
Vue
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>
|