Files
xiaotutongxue/frontend-pc/src/views/audit/FilePreview.vue
T
lovife 623e6393d5 fix(pc): 弹窗遮罩改毛玻璃并清掉 9 处提示折行
遮罩底色被 Naive 写死在 css-render 里(Modal / Drawer 主题没有 mask token,
themeOverrides 改不到),只能上 CSS;加 backdrop-filter 后遮罩后面的页面糊掉、
弹层本体仍是纯白,NModal / NDrawer / useDialog 共用 .n-modal-mask 一条规则覆盖。

折行的根因是格子宽度写死(半宽 264px / span 8 192px / 定宽 160px),与窗口宽度无关:
能压短的压短;「文件名预览」那处是 .n-form-item-blank 属于 flex 行、把提示挤成 3 行,
改成包一层块级容器;材料预览失败的占位在 108px 小格里曾折 3 行且被裁掉。

约定写进 frontend-pc/AGENTS.md 第 11 条与 code-css skill;实测与刻意不改的清单见
docs/交接摘要.md 的 §一·四十二。
2026-10-14 15:34:40 +08:00

305 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { AuditFileItem } from '@/apis/xt/audit'
import { NButton, NModal, NRadioButton, NRadioGroup, NSpace, NSpin, NTag } from 'naive-ui'
import { computed, onMounted, ref, watch } from 'vue'
import { FILE_VALIDATE, getAuditFileUrl } from '@/apis/xt/audit'
import { tagType } from '@/utils/tag'
/**
* 材料预览(审核端)。
*
* ## 为什么单独封一个组件
*
* 待办卡片、审核详情、班主任端都要显示材料,而"取签名链接"这件事**不能各写一份**:
* 一处忘了走审核端通道(`/audit/files/*`)就会去调学生端的接口,
* 而那条通道校验的是"这份是不是你传的",审核人必然拿不到。
*
* ## 三条硬规则
*
* 1. **链接是现取的,不缓存、不入库、不落 localStorage**。七牛空间是私有的,
* 下载走签名链接且有有效期(默认 30 分钟);把链接存起来,明天打开就是坏的。
* 2. **原始件(`original=true`)要单独再取一次**,而且只在用户真的切到"原始件/并排"
* 时才取 —— 审核大多数时候看的是规范件,没必要每次都多打一个接口。
* 3. **原始件取不到不是错误**:`cleanExpiredRawFiles` 定时任务默认只保留 30 天,
* 过期后原始件已被清理。这种情况给一句说明,而不是弹一个红色报错 ——
* "压缩前后对比"本来就是一个抽查动作,不是审核的必经步骤。
*/
defineOptions({ name: 'FilePreview' })
const props = defineProps<{
file: AuditFileItem
/** 已经拿到的链接(待办卡片里的 `materialThumbs` 就是现成的签名链接) */
url?: string
}>()
type Mode = 'normal' | 'raw' | 'compare'
const loading = ref(false)
const rawLoading = ref(false)
const src = ref('')
const rawSrc = ref('')
const showModal = ref(false)
const mode = ref<Mode>('normal')
const fileName = computed(() => props.file.standardName || props.file.originalName || '(无文件名)')
const sizeText = computed(() => {
const f = props.file
return f.finalSizeKb ? `${f.finalSizeKb} KB` : '—'
})
const pixelText = computed(() => {
const f = props.file
if (!f.width || !f.height) {
return '—'
}
return `${f.width}×${f.height}${f.dpi ? ` · ${f.dpi}dpi` : ''}`
})
const validateMeta = computed(() => {
const s = props.file.validateStatus
if (s === null || s === undefined) {
return null
}
return FILE_VALIDATE[s] ?? null
})
async function loadNormal() {
if (props.url) {
src.value = props.url
return
}
if (props.file.previewUrl) {
// 详情接口已经带了现取的链接,直接用,省一次往返
src.value = props.file.previewUrl
return
}
loading.value = true
try {
const res = await getAuditFileUrl(props.file.id)
src.value = res?.url ?? ''
} catch {
// 取不到链接不是致命错误:材料本身还在,只是这次看不了。
// 所以只留一个"预览取不到"的占位,不弹错误 —— 审核几十份材料时弹窗会淹没页面。
src.value = ''
} finally {
loading.value = false
}
}
/** 原始件:每次需要时现取(签名链接会过期,不能复用上一次的) */
async function loadRaw() {
if (rawLoading.value) {
return
}
rawLoading.value = true
try {
const res = await getAuditFileUrl(props.file.id, true)
rawSrc.value = res?.url ?? ''
} catch {
// 原始件已被清理(默认保留 30 天)或链接取不到:留空,由界面给说明
rawSrc.value = ''
} finally {
rawLoading.value = false
}
}
watch(mode, (m) => {
if ((m === 'raw' || m === 'compare') && !rawSrc.value) {
void loadRaw()
}
})
async function open() {
if (!src.value) {
return
}
showModal.value = true
mode.value = 'normal'
// 重新打开时把上一次的原始件链接丢掉:它随时可能过期,留着会显示成"坏图"
rawSrc.value = ''
}
function openInNewTab() {
const target = mode.value === 'raw' ? rawSrc.value : src.value
if (target) {
window.open(target, '_blank')
}
}
onMounted(loadNormal)
</script>
<template>
<div class="fp">
<div class="fp__box" :class="{ 'fp__box--empty': !src && !loading }" @click="open">
<NSpin v-if="loading" :size="16" />
<img v-else-if="src" :src="src" class="fp__img" alt="材料预览" />
<div v-else class="fp__fail">
预览取不到<br>(链接已过期)
</div>
</div>
<div class="fp__name" :title="fileName">{{ fileName }}</div>
<NModal
v-model:show="showModal"
preset="card"
:style="{ width: '920px' }"
:title="fileName"
:bordered="false"
>
<!-- 弹窗顶栏:切换器与动作按钮统一默认尺寸(同一行不要大小混用) -->
<div class="fp__bar">
<NRadioGroup v-model:value="mode">
<NRadioButton value="normal">规范件</NRadioButton>
<NRadioButton value="raw">原始件</NRadioButton>
<NRadioButton value="compare">并排对比</NRadioButton>
</NRadioGroup>
<NSpace align="center" :size="6">
<NTag size="small" :bordered="false">{{ sizeText }}</NTag>
<NTag size="small" :bordered="false">{{ pixelText }}</NTag>
<NTag v-if="validateMeta" size="small" :bordered="false" :type="tagType(validateMeta.color)">
{{ validateMeta.text }}
</NTag>
<NButton quaternary @click="openInNewTab">在新标签打开</NButton>
</NSpace>
</div>
<div v-if="mode === 'compare'" class="fp__compare">
<div class="fp__pane">
<div class="fp__pane-head">规范件(交给机构的那一份)</div>
<img v-if="src" :src="src" class="fp__big" alt="规范件" />
</div>
<div class="fp__pane">
<div class="fp__pane-head">原始件(学生传上来的那一份)</div>
<NSpin v-if="rawLoading" class="fp__spin" :size="20" />
<img v-else-if="rawSrc" :src="rawSrc" class="fp__big" alt="原始件" />
<div v-else class="fp__missing">
原始件没取到。<br>
原始件默认只保留 30 天(用于复核与重出),过期后已被清理 ——
规范件不受影响,审核按规范件判断即可。
</div>
</div>
</div>
<div v-else class="fp__single">
<NSpin v-if="mode === 'raw' && rawLoading" :size="20" />
<template v-else-if="mode === 'raw'">
<img v-if="rawSrc" :src="rawSrc" class="fp__big" alt="原始件" />
<div v-else class="fp__missing">
原始件没取到(默认只保留 30 天,过期已清理)。规范件不受影响。
</div>
</template>
<img v-else :src="src" class="fp__big" alt="规范件" />
</div>
</NModal>
</div>
</template>
<style scoped>
.fp {
width: 108px;
}
/* 缩略图格子:浅灰底 + 弱描边,与外层白格区分开 */
.fp__box {
display: flex;
align-items: center;
justify-content: center;
width: 108px;
height: 108px;
overflow: hidden;
cursor: zoom-in;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 6px;
}
.fp__box--empty {
cursor: default;
}
.fp__img {
width: 100%;
height: 100%;
object-fit: contain;
}
.fp__fail {
font-size: 12px;
color: var(--xt-text-3);
text-align: center;
}
.fp__name {
margin-top: 4px;
overflow: hidden;
font-size: 12px;
color: var(--xt-text-2);
text-overflow: ellipsis;
white-space: nowrap;
}
.fp__bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
margin-bottom: 14px;
border-bottom: 1px solid var(--xt-card-border-weak);
}
.fp__single {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}
.fp__compare {
display: flex;
gap: 16px;
}
.fp__pane {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.fp__pane-head {
font-size: 12px;
color: var(--xt-text-3);
}
.fp__big {
width: 100%;
max-height: 62vh;
object-fit: contain;
background: var(--xt-band-bg);
border: 1px solid var(--xt-card-border-weak);
border-radius: 6px;
}
.fp__missing {
padding: 16px;
font-size: 12px;
line-height: 1.9;
color: var(--xt-text-3);
background: var(--xt-band-bg);
border: 1px dashed var(--xt-card-border);
border-radius: 8px;
}
.fp__spin {
display: flex;
justify-content: center;
padding: 40px 0;
}
</style>