623e6393d5
遮罩底色被 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 的 §一·四十二。
305 lines
8.4 KiB
Vue
305 lines
8.4 KiB
Vue
<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>
|