refactor(pc): 从 Gi Admin Pro + Arco 重搭为纯 Vue 3 + Naive UI
旧模板的 193 个文件整体删除,换成纯 Vue 3.5 + Naive UI 2.42 的树。
- 去掉 auto-import(组件与 API 全显式 import)、v-hasPerm(改 user.has('码'),全库 8 处)、
Stylelint;n-tag 的 type 统一过 utils/tag.ts 转换(Arco 色词直接透传会静默变灰)
- 图标改为构建期抽取:scripts/extract-pc-icons.py -> icons/icon-data.ts(37 个),
内网离线部署不会白屏
- 重搭后修掉 6 类"编译 / lint / typecheck 全绿、界面上就是坏的"故障:
① NGrid 的 cols 写成像素值(不是断点名 -> 永远只 1 列,工作台因此错乱)
② n-tab-pane 用 tab 属性(只渲染空注释节点,整排 tab 一个字都不显示)
③ 漏 import 被当未知自定义元素(样式整块丢) ④ .n-card__content 类名不存在
(高度链第一环失效,表格行在 DOM 里但屏幕上没有) ⑤ 填报单状态映射错位一格
⑥ 有标题的卡片没有上内边距(naive 的 padding-top 只给"内容区是长子"的卡片)
- 卡片式版面提成全局基线:styles/index.scss 的 xt-* 令牌 + .page-card / .toolbar /
.section-title,页面只引用、不再各写一套
- 品牌:改名「深学在线」,文字标换成几何标(方 = 卷宗 / 环 = 在线,不用文字字形);
index.html 的页签图标从 404 的 /favicon.ico 改指真实存在的 /favicon.svg
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user