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:
2026-10-10 16:48:46 +08:00
parent 0bbac7d228
commit 5b877451eb
270 changed files with 12964 additions and 25889 deletions
+304
View File
@@ -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>