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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user