feat(h5): 学生端材料预览 + 删掉部署里的 MinIO

一、部署配置清理
  docker-compose.yml 里的 MinIO 删了(连 minio-data 卷)。它当初是
  "M0 阶段不写入但先起好 —— 本地/生产走同一套 S3 接口",但代码最终走
  七牛官方 SDK,那个容器**从来没被连过** —— 留着只会让人以为"本地还有个
  对象存储要用"。.env.example / .env 的 MINIO_* 换成 QINIU_* + XT_STORAGE_*。

  ⚠️ 删的时候我自己犯了个错:用 volumes:\n 当插入锚点,结果匹配到了
     **pg 服务内部的 `  volumes:`**,把注释插进 pg 定义里还把它顶到第 0 列,
     **YAML 结构就这么坏了**。好在写完立刻用 yaml.safe_load 复查(services: [pg, redis])
     才发现。教训:改 YAML 之后一定要解析一遍,别靠肉眼看缩进。

二、学生端材料预览(新功能)
  已传材料每行加「预览」,点开全屏浮层(深底 —— 材料照片多是白底证件件,
  浅底会看不清边界),点图片以外关闭。

  **两种存储两种取法,不能只写一种**:
    · 七牛:接口返回带签名的绝对 URL,直接给 <image src> 用
    · 本地磁盘:那个接口要带 Authorization,而 <image src> 发不了请求头 ——
      先用 uni.request 取字节,再转 blob URL
  只写七牛那一种,本地开发时预览会一片空白,而**原因完全看不出来**。
  签名链接有有效期(30 分钟),所以每次预览都现取,不缓存 URL。

  顺手修两个后端问题:
    · LocalStorageService.url() 返回的路径指向一个**不存在的接口**
      (我建的是 /{id}/content,不是 ?path=)→ 改由 MaterialFileService 按
      provider 决定:七牛给签名链接、本地给 /{id}/content(按 id 取能复用归属校验)
    · /content 原来固定 application/octet-stream —— 表现是**点开身份证照片
      变成下载一个文件**。现在按文件名推 Content-Type + inline

三、浏览器实测
  点预览 → 浮层打开、图片真的解码(naturalWidth=900、complete=true);
  直接 fetch 那个签名 URL → 200 / 17728 字节。
  中间发现一个视觉问题:**浮层标题与「关闭」被 uni-app 导航栏盖住了**
  (page-head 的 z-index 是 998,我给的 95)—— 看起来像"标题没渲染出来"。
  改成 9999 + padding-top 让出导航高度;问题清单浮层一并提到 9998。复验通过。

四、顺带
  dev-seed-warn-state.py 改为没有可用草稿时自己建一份 —— 原来写死 submission id,
  被 E2E 清理后就报"填报单不存在或不属于你",而那看起来像权限问题。

验收:e2e-m3-file 32/32、m3-submit 31/31、m1-storage 16/16
This commit is contained in:
2026-09-29 15:16:36 +08:00
parent d8463a051f
commit fe7615d12b
7 changed files with 263 additions and 40 deletions
+122 -2
View File
@@ -88,7 +88,12 @@
<view v-for="f in (filesByMaterial[m.materialCode] || [])" :key="f.id" class="fill__file">
<view class="fill__file-line">
<text class="fill__file-name">{{ f.standardName || f.originalName }}</text>
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
<view class="fill__file-ops">
<!-- 预览:学生最在意的是"我传对了吗"。没有预览他只能靠文件名猜,
而"看着差不多"的照片(比如背面传成了正面)只有看才认得出 -->
<text class="fill__file-view" @click="preview(f)">预览</text>
<text v-if="filesDeletable" class="fill__file-del" @click="removeFile(f)">删除</text>
</view>
</view>
<view class="fill__file-meta">
<text v-if="f.standardName && f.rawSize > f.finalSize">
@@ -121,6 +126,16 @@
用页面内浮层而不是 uni.showModal:showModal 只能放一段纯文本,
而问题清单要分级上色、每条要带规则编号 —— 这些正是学生能看懂"哪里不对"的关键。
全屏纯文本会让人只看到"提交失败",然后放弃 -->
<!-- 材料预览。全屏浮层,点图片以外的地方关闭 —— 学生看完就退回表单,不要多一步"关闭" -->
<view v-if="previewSrc" class="fill__viewer" @click="closePreview">
<view class="fill__viewer-head">
<text class="fill__viewer-name">{{ previewName }}</text>
<text class="fill__viewer-close">关闭</text>
</view>
<image class="fill__viewer-img" :src="previewSrc" mode="widthFix" @click.stop />
<view class="fill__viewer-hint">点图片以外的地方关闭</view>
</view>
<view v-if="report" class="fill__mask" @click="report = null">
<view class="fill__sheet" @click.stop>
<view class="fill__sheet-head">
@@ -551,6 +566,87 @@ function removeFile(f: UploadedFile) {
})
}
// ── 材料预览 ─────────────────────────────────
const previewSrc = ref<string | null>(null)
const previewName = ref('')
const previewing = ref(false)
/** 扩展名 → MIME。做 blob 时要用,猜错的表现是"图裂了"。 */
function mimeOf(name: string): string {
const ext = (name.split('.').pop() || '').toLowerCase()
if (ext === 'png') return 'image/png'
if (ext === 'pdf') return 'application/pdf'
if (ext === 'webp') return 'image/webp'
return 'image/jpeg'
}
/**
* 预览一份材料。
*
* <p>两种存储两种取法,**不能只写一种**:
* <ul>
* <li>七牛:接口返回的是**带签名的绝对 URL**,可以直接交给 {@code <image src>};</li>
* <li>本地磁盘:那个接口要带 Authorization 头,而 {@code <image src>} **发不了请求头** ——
* 所以先用 uni.request 把字节取回来,再转成 blob URL。
* 只写七牛那一种,本地开发时预览会一片空白,而原因完全看不出来。</li>
* </ul>
*
* <p>⚠️ 签名链接有有效期(默认 30 分钟),所以**每次预览都现取**,
* 不要把 URL 缓存起来 —— 缓存那份过期后是 403,而学生只会觉得"打不开"。
*/
async function preview(f: UploadedFile) {
if (previewing.value) return
previewing.value = true
uni.showLoading({ title: '加载中…' })
try {
const r = await request<{ url: string, name: string }>({
url: `/api/v1/mp/files/${f.id}/url`,
token: token()
})
previewName.value = r.name || f.standardName || f.originalName
if (/^https?:\/\//i.test(r.url)) {
previewSrc.value = r.url
} else {
const buf = await new Promise<ArrayBuffer>((resolve, reject) => {
uni.request({
url: API_BASE + r.url,
header: token() ? { Authorization: `Bearer ${token()}` } : {},
responseType: 'arraybuffer',
success: (res) => {
if (res.statusCode === 200) resolve(res.data as ArrayBuffer)
else reject(new Error(`打不开(HTTP ${res.statusCode})`))
},
fail: (e) => reject(new Error(e.errMsg || '打不开'))
})
})
revokePreview()
previewSrc.value = URL.createObjectURL(new Blob([buf], { type: mimeOf(previewName.value) }))
}
} catch (e) {
uni.showModal({
title: '打不开这份材料',
content: e instanceof Error ? e.message : '请稍后重试',
showCancel: false
})
} finally {
previewing.value = false
uni.hideLoading()
}
}
/** blob URL 是浏览器持有的内存,不释放就会一直占着(学生可能连看十几张)。 */
function revokePreview() {
if (previewSrc.value && previewSrc.value.startsWith('blob:')) {
URL.revokeObjectURL(previewSrc.value)
}
}
function closePreview() {
revokePreview()
previewSrc.value = null
}
/**
* 提交按钮的入口。
*
@@ -770,9 +866,33 @@ page { background-color: $page-bg; }
background: #f0f0f0; border-radius: 12rpx;
}
&__file-ops { display: flex; flex-shrink: 0; align-items: baseline; gap: 20rpx; }
&__file-view { font-size: 24rpx; color: $primary; }
// ── 材料预览(全屏,深底 —— 材料照片常是白底的证件件,浅底会看不清边界)──
//
// ⚠️ z-index 必须**高过 uni-app 的页面导航栏**(它是 998),否则浮层的标题与
// 「关闭」会被导航栏盖住 —— 而那看起来像"标题没渲染出来"。
// 同时用 padding-top 给导航栏让出位置(44px ≈ 88rpx),再叠加刘海屏安全区。
&__viewer {
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9999;
display: flex; flex-direction: column; overflow-y: auto; background: rgba(0, 0, 0, .92);
padding-top: calc(88rpx + constant(safe-area-inset-top));
padding-top: calc(88rpx + env(safe-area-inset-top));
}
&__viewer-head {
display: flex; align-items: center; justify-content: space-between;
padding: 32rpx 28rpx 16rpx;
}
&__viewer-name { flex: 1; font-size: 28rpx; color: #fff; word-break: break-all; }
&__viewer-close { flex-shrink: 0; margin-left: 24rpx; font-size: 28rpx; color: #fff; }
&__viewer-img { width: 100%; }
&__viewer-hint { padding: 24rpx; font-size: 24rpx; text-align: center; color: rgba(255, 255, 255, .6); }
// ── 校验问题清单(红黄绿)──
&__mask {
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 90;
/* 同样要盖过 uni-app 的导航栏(998),否则清单高一点时顶部会被压住 */
position: fixed; left: 0; right: 0; top: 0; bottom: 0; z-index: 9998;
display: flex; align-items: flex-end; background: rgba(0, 0, 0, .45);
}
&__sheet {