mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 13:23:17 +00:00
- Sirve documentos con Content-Type real e inline por defecto (query disposition) - DocumentPreviewDialog reconstruye blob con MIME correcto para PDF/imagen - RPC y DELETE para eliminar documentos del expediente; botón en UI - Permiso IAM documents.delete; store archiva todas las versiones previas del tipo - Limpia FileUpload tras subir; is_current robusto en listas vigente/histórico Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
167 lines
4.6 KiB
Vue
167 lines
4.6 KiB
Vue
<template>
|
|
<Dialog
|
|
v-model:visible="visible"
|
|
modal
|
|
:header="title"
|
|
class="document-preview-dialog"
|
|
:style="{ width: 'min(920px, 96vw)' }"
|
|
:content-style="{ padding: '0' }"
|
|
@hide="onHide"
|
|
>
|
|
<div class="document-preview-body">
|
|
<div v-if="loading" class="document-preview-state">
|
|
<ProgressSpinner stroke-width="4" />
|
|
<span class="muted">Cargando vista previa…</span>
|
|
</div>
|
|
<div v-else-if="error" class="document-preview-state">
|
|
<i class="pi pi-exclamation-triangle" />
|
|
<span>{{ error }}</span>
|
|
</div>
|
|
<img v-else-if="kind === 'image' && url" :src="url" :alt="title" class="document-preview-image" />
|
|
<iframe v-else-if="kind === 'pdf' && url" :src="url" class="document-preview-frame" title="Vista previa PDF" />
|
|
<div v-else class="document-preview-state">
|
|
<i class="pi pi-file" />
|
|
<span>Este archivo no tiene vista previa en el panel.</span>
|
|
<Button label="Descargar" icon="pi pi-download" outlined size="small" @click="downloadFile" />
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Descargar" icon="pi pi-download" text :disabled="!url" @click="downloadFile" />
|
|
<Button label="Cerrar" severity="secondary" @click="visible = false" />
|
|
</template>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
const props = defineProps<{
|
|
modelValue: boolean;
|
|
path: string;
|
|
name: string;
|
|
mime?: string | null;
|
|
}>();
|
|
const emit = defineEmits<{ "update:modelValue": [value: boolean] }>();
|
|
|
|
const { api } = useApi();
|
|
const visible = computed({
|
|
get: () => props.modelValue,
|
|
set: (value) => emit("update:modelValue", value),
|
|
});
|
|
const title = computed(() => props.name || "Documento");
|
|
const loading = ref(false);
|
|
const error = ref("");
|
|
const url = ref("");
|
|
const blobRef = ref<Blob | null>(null);
|
|
|
|
const kind = computed<"image" | "pdf" | "other">(() => {
|
|
const mime = (props.mime || blobRef.value?.type || "").toLowerCase();
|
|
const name = props.name.toLowerCase();
|
|
if (mime.startsWith("image/") || /\.(jpe?g|png|gif|webp|bmp)$/i.test(name)) return "image";
|
|
if (mime.includes("pdf") || name.endsWith(".pdf")) return "pdf";
|
|
return "other";
|
|
});
|
|
|
|
function previewFetchPath(path: string) {
|
|
const sep = path.includes("?") ? "&" : "?";
|
|
return `${path}${sep}disposition=inline`;
|
|
}
|
|
|
|
function resolvedPreviewMime(raw: Blob): string {
|
|
const fromProp = (props.mime || "").trim();
|
|
if (fromProp) return fromProp;
|
|
const fromBlob = (raw.type || "").trim();
|
|
if (fromBlob && fromBlob !== "application/octet-stream") return fromBlob;
|
|
const name = props.name.toLowerCase();
|
|
if (name.endsWith(".pdf")) return "application/pdf";
|
|
if (/\.(jpe?g)$/.test(name)) return "image/jpeg";
|
|
if (name.endsWith(".png")) return "image/png";
|
|
if (name.endsWith(".webp")) return "image/webp";
|
|
if (name.endsWith(".gif")) return "image/gif";
|
|
return fromBlob || "application/octet-stream";
|
|
}
|
|
|
|
async function loadPreview() {
|
|
revoke();
|
|
error.value = "";
|
|
blobRef.value = null;
|
|
if (!props.path) return;
|
|
loading.value = true;
|
|
try {
|
|
const raw = await api<Blob>(previewFetchPath(props.path));
|
|
const mime = resolvedPreviewMime(raw);
|
|
const blob = mime === raw.type ? raw : new Blob([raw], { type: mime });
|
|
blobRef.value = blob;
|
|
url.value = URL.createObjectURL(blob);
|
|
} catch (err: unknown) {
|
|
error.value = err instanceof Error ? err.message : "No se pudo abrir el documento";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function downloadFile() {
|
|
if (!url.value) return;
|
|
const a = document.createElement("a");
|
|
a.href = url.value;
|
|
a.download = props.name || "documento";
|
|
a.rel = "noopener";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
}
|
|
|
|
function revoke() {
|
|
if (url.value) URL.revokeObjectURL(url.value);
|
|
url.value = "";
|
|
}
|
|
|
|
function onHide() {
|
|
revoke();
|
|
blobRef.value = null;
|
|
error.value = "";
|
|
}
|
|
|
|
watch(
|
|
() => [props.modelValue, props.path] as const,
|
|
([open]) => {
|
|
if (open) loadPreview();
|
|
else onHide();
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.document-preview-body {
|
|
min-height: 360px;
|
|
max-height: min(78vh, 760px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #f4f6f8;
|
|
}
|
|
.document-preview-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 28px 20px;
|
|
color: #5d6e84;
|
|
text-align: center;
|
|
}
|
|
.document-preview-state .pi {
|
|
font-size: 28px;
|
|
color: #8a96a8;
|
|
}
|
|
.document-preview-image {
|
|
display: block;
|
|
max-width: 100%;
|
|
max-height: min(78vh, 760px);
|
|
object-fit: contain;
|
|
background: #fff;
|
|
}
|
|
.document-preview-frame {
|
|
width: 100%;
|
|
height: min(78vh, 760px);
|
|
border: 0;
|
|
background: #fff;
|
|
}
|
|
</style>
|